Version 3 (#40)
Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
@@ -13,9 +13,21 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\JobsJobsJobs.Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Application", "JobsJobsJobs\Application\JobsJobsJobs.Application.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Citizens", "JobsJobsJobs\Citizens\JobsJobsJobs.Citizens.fsproj", "{4C184AB8-DDA7-4545-BC84-A4ACCBE29764}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Common", "JobsJobsJobs\Common\JobsJobsJobs.Common.fsproj", "{D6E4A943-5113-41ED-A547-8D3BE5516DC0}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Home", "JobsJobsJobs\Home\JobsJobsJobs.Home.fsproj", "{974AC330-FA47-479D-8927-D40E82EFE223}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Listings", "JobsJobsJobs\Listings\JobsJobsJobs.Listings.fsproj", "{0BE66D08-95AC-4E0F-8A79-0A97602A3E08}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Profiles", "JobsJobsJobs\Profiles\JobsJobsJobs.Profiles.fsproj", "{0B89D606-A094-4E82-8F8A-9D72D6A0E805}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SuccessStories", "JobsJobsJobs\SuccessStories\JobsJobsJobs.SuccessStories.fsproj", "{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.V3Migration", "JobsJobsJobs\JobsJobsJobs.V3Migration\JobsJobsJobs.V3Migration.fsproj", "{DC3E225D-9720-44E8-86AE-DEE71262C9F0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -23,14 +35,38 @@ Global
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{974AC330-FA47-479D-8927-D40E82EFE223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{974AC330-FA47-479D-8927-D40E82EFE223}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{974AC330-FA47-479D-8927-D40E82EFE223}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{974AC330-FA47-479D-8927-D40E82EFE223}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0BE66D08-95AC-4E0F-8A79-0A97602A3E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0BE66D08-95AC-4E0F-8A79-0A97602A3E08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0BE66D08-95AC-4E0F-8A79-0A97602A3E08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0BE66D08-95AC-4E0F-8A79-0A97602A3E08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -39,7 +75,13 @@ Global
|
||||
SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C}
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{974AC330-FA47-479D-8927-D40E82EFE223} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{0BE66D08-95AC-4E0F-8A79-0A97602A3E08} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
@@ -1,5 +0,0 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/standard",
|
||||
"@vue/typescript/recommended"
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
globals: {
|
||||
defineProps: "readonly",
|
||||
defineEmits: "readonly",
|
||||
defineExpose: "readonly",
|
||||
withDefaults: "readonly"
|
||||
},
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"vue/no-multiple-template-root": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/script-setup-uses-vars": 1,
|
||||
"quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
"func-call-spacing": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
23
src/JobsJobsJobs/App/.gitignore
vendored
23
src/JobsJobsJobs/App/.gitignore
vendored
@@ -1,23 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
23423
src/JobsJobsJobs/App/package-lock.json
generated
23423
src/JobsJobsJobs/App/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "jobs-jobs-jobs",
|
||||
"version": "2.2.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^6.9.96",
|
||||
"@vuelidate/core": "^2.0.0-alpha.24",
|
||||
"@vuelidate/validators": "^2.0.0-alpha.21",
|
||||
"@vueuse/core": "^8.9.1",
|
||||
"bootstrap": "^5.1.0",
|
||||
"core-js": "^3.16.3",
|
||||
"date-fns": "^2.23.0",
|
||||
"date-fns-tz": "^1.1.6",
|
||||
"dompurify": "^2.3.1",
|
||||
"marked": "^4.0.18",
|
||||
"vue": "^3.2.6",
|
||||
"vue-router": "^4.0.11",
|
||||
"vuex": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.1.2",
|
||||
"@types/dompurify": "^2.2.3",
|
||||
"@types/marked": "^4.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"@typescript-eslint/parser": "^5.30.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/compiler-sfc": "^3.2.6",
|
||||
"@vue/eslint-config-standard": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-n": "^15.2.4",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-standard": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.2.0",
|
||||
"sass": "~1.37.0",
|
||||
"sass-loader": "^10.0.0",
|
||||
"typescript": "~4.5.0",
|
||||
"vue-cli-plugin-pug": "~2.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<link rel="stylesheet" href="https://necolas.github.io/normalize.css/latest/normalize.css">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template lang="pug">
|
||||
.jjj-app
|
||||
app-nav
|
||||
.jjj-main
|
||||
title-bar
|
||||
main.container-fluid: router-view(v-slot="{ Component }"): transition(name="fade" mode="out-in")
|
||||
component(:is="Component")
|
||||
app-footer
|
||||
app-toaster
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted } from "vue"
|
||||
|
||||
import "bootstrap/dist/css/bootstrap.min.css"
|
||||
|
||||
import { Citizen } from "./api"
|
||||
import { Mutations, useStore } from "./store"
|
||||
import AppFooter from "./components/layout/AppFooter.vue"
|
||||
import AppNav from "./components/layout/AppNav.vue"
|
||||
import AppToaster from "./components/layout/AppToaster.vue"
|
||||
import TitleBar from "./components/layout/TitleBar.vue"
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
AppFooter,
|
||||
AppNav,
|
||||
AppToaster,
|
||||
TitleBar
|
||||
}
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
||||
onMounted(() => store.commit(Mutations.SetTitle, "Jobs, Jobs, Jobs"))
|
||||
|
||||
/**
|
||||
* Return "Yes" for true and "No" for false
|
||||
*
|
||||
* @param cond The condition to be checked
|
||||
* @returns "Yes" for true, "No" for false
|
||||
*/
|
||||
export function yesOrNo (cond : boolean) : string {
|
||||
return cond ? "Yes" : "No"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a citizen (the first available among real, display, or Mastodon handle)
|
||||
*
|
||||
* @param cit The citizen
|
||||
* @returns The citizen's display name
|
||||
*/
|
||||
export function citizenName (cit : Citizen) : string {
|
||||
return cit.realName ?? cit.displayName ?? cit.mastodonUser
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
// Overall app styles
|
||||
html
|
||||
scroll-behavior: smooth
|
||||
a:link,
|
||||
a:visited
|
||||
text-decoration: none
|
||||
a:not(.btn):hover
|
||||
text-decoration: underline
|
||||
label.jjj-required::after
|
||||
color: red
|
||||
content: ' *'
|
||||
.jjj-heading-label
|
||||
display: inline-block
|
||||
font-size: 1rem
|
||||
text-transform: uppercase
|
||||
// Styles for this component
|
||||
.jjj-app
|
||||
display: flex
|
||||
flex-direction: row
|
||||
.jjj-main
|
||||
flex-grow: 1
|
||||
// Route transitions
|
||||
.fade-enter-active,
|
||||
.fade-leave-active
|
||||
transition: opacity 0.125s ease
|
||||
.fade-enter-from,
|
||||
.fade-leave-to
|
||||
opacity: 0
|
||||
</style>
|
||||
@@ -1,383 +0,0 @@
|
||||
import {
|
||||
Citizen,
|
||||
Continent,
|
||||
Count,
|
||||
Instance,
|
||||
Listing,
|
||||
ListingExpireForm,
|
||||
ListingForm,
|
||||
ListingForView,
|
||||
ListingSearch,
|
||||
LogOnSuccess,
|
||||
Profile,
|
||||
ProfileForm,
|
||||
ProfileForView,
|
||||
ProfileSearch,
|
||||
ProfileSearchResult,
|
||||
PublicSearch,
|
||||
PublicSearchResult,
|
||||
StoryEntry,
|
||||
StoryForm,
|
||||
Success
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Create a URL that will access the API
|
||||
* @param url The partial URL for the API
|
||||
* @returns A full URL for the API
|
||||
*/
|
||||
const apiUrl = (url : string) : string => `/api/${url}`
|
||||
|
||||
/**
|
||||
* Create request init parameters
|
||||
*
|
||||
* @param method The method by which the request should be executed
|
||||
* @param user The currently logged-on user
|
||||
* @returns RequestInit parameters
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
const reqInit = (method : string, user : LogOnSuccess, body : any | undefined = undefined) : RequestInit => {
|
||||
const headers = new Headers()
|
||||
headers.append("Authorization", `Bearer ${user.jwt}`)
|
||||
if (body) {
|
||||
headers.append("Content-Type", "application/json")
|
||||
return {
|
||||
headers,
|
||||
method,
|
||||
cache: "no-cache",
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
return {
|
||||
headers,
|
||||
method
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a result for an API call
|
||||
*
|
||||
* @param resp The response received from the API
|
||||
* @param action The action being performed (used in error messages)
|
||||
* @returns The expected result (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
async function apiResult<T> (resp : Response, action : string) : Promise<T | undefined | string> {
|
||||
if (resp.status === 200) return await resp.json() as T
|
||||
if (resp.status === 404) return undefined
|
||||
return `Error ${action} - ${await resp.text()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an update via the API
|
||||
*
|
||||
* @param resp The response received from the API
|
||||
* @param action The action being performed (used in error messages)
|
||||
* @returns True (if the response is a success) or an error string
|
||||
*/
|
||||
async function apiSend (resp : Response, action : string) : Promise<boolean | string> {
|
||||
if (resp.status === 200) return true
|
||||
// HTTP 422 (Unprocessable Entity) is what the API returns for an expired JWT
|
||||
if (resp.status === 422) return `Error ${action} - Your login has expired; refresh this page to renew it`
|
||||
return `Error ${action} - (${resp.status}) ${await resp.text()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an API action that does not return a result
|
||||
*
|
||||
* @param resp The response received from the API call
|
||||
* @param action The action being performed (used in error messages)
|
||||
* @returns Undefined (if successful), or an error string
|
||||
*/
|
||||
const apiAction = async (resp : Response, action : string) : Promise<string | undefined> => {
|
||||
if (resp.status === 200) return undefined
|
||||
return `Error ${action} - ${await resp.text()}`
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
/** API functions for citizens */
|
||||
citizen: {
|
||||
|
||||
/**
|
||||
* Log a citizen on
|
||||
*
|
||||
* @param abbr The abbreviation of the Mastodon instance that issued the code
|
||||
* @param code The authorization code from Mastodon
|
||||
* @returns The user result, or an error
|
||||
*/
|
||||
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
|
||||
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
|
||||
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
||||
return `Error logging on - ${await resp.text()}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a citizen by their ID
|
||||
*
|
||||
* @param id The citizen ID to be retrieved
|
||||
* @param user The currently logged-on user
|
||||
* @returns The citizen, or an error
|
||||
*/
|
||||
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string | undefined> =>
|
||||
apiResult<Citizen>(await fetch(apiUrl(`citizen/${id}`), reqInit("GET", user)), `retrieving citizen ${id}`),
|
||||
|
||||
/**
|
||||
* Delete the current citizen's entire Jobs, Jobs, Jobs record
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns Undefined if successful, an error if not
|
||||
*/
|
||||
delete: async (user : LogOnSuccess) : Promise<string | undefined> =>
|
||||
apiAction(await fetch(apiUrl("citizen"), reqInit("DELETE", user)), "deleting citizen")
|
||||
},
|
||||
|
||||
/** API functions for continents */
|
||||
continent: {
|
||||
|
||||
/**
|
||||
* Get all continents
|
||||
*
|
||||
* @returns All continents, or an error
|
||||
*/
|
||||
all: async () : Promise<Continent[] | string | undefined> =>
|
||||
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
||||
},
|
||||
|
||||
/** API functions for instances */
|
||||
instances: {
|
||||
|
||||
/**
|
||||
* Get all Mastodon instances we support
|
||||
*
|
||||
* @returns All instances, or an error
|
||||
*/
|
||||
all: async () : Promise<Instance[] | string | undefined> =>
|
||||
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
|
||||
},
|
||||
|
||||
/** API functions for job listings */
|
||||
listings: {
|
||||
|
||||
/**
|
||||
* Add a new job listing
|
||||
*
|
||||
* @param listing The profile data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the addition was successful, an error string if not
|
||||
*/
|
||||
add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl("listings"), reqInit("POST", user, listing)), "adding job listing"),
|
||||
|
||||
/**
|
||||
* Expire a job listing
|
||||
*
|
||||
* @param id The ID of the job listing to be expired
|
||||
* @param form The information needed to expire the form
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the action was successful, an error string if not
|
||||
*/
|
||||
expire: async (id : string, listing : ListingExpireForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl(`listing/${id}`), reqInit("PATCH", user, listing)), "expiring job listing"),
|
||||
|
||||
/**
|
||||
* Retrieve the job listings posted by the current citizen
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listings the user has posted, or an error string
|
||||
*/
|
||||
mine: async (user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> =>
|
||||
apiResult<ListingForView[]>(await fetch(apiUrl("listings/mine"), reqInit("GET", user)),
|
||||
"retrieving your job listings"),
|
||||
|
||||
/**
|
||||
* Retrieve a job listing
|
||||
*
|
||||
* @param id The ID of the job listing to retrieve
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
|
||||
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit("GET", user)), "retrieving job listing"),
|
||||
|
||||
/**
|
||||
* Retrieve a job listing for viewing (also contains continent information)
|
||||
*
|
||||
* @param id The ID of the job listing to retrieve
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ListingForView | undefined | string> =>
|
||||
apiResult<ListingForView>(await fetch(apiUrl(`listing/${id}/view`), reqInit("GET", user)),
|
||||
"retrieving job listing"),
|
||||
|
||||
/**
|
||||
* Search for job listings using the given parameters
|
||||
*
|
||||
* @param query The listing search parameters
|
||||
* @param user The currently logged-on user
|
||||
* @returns The matching job listings (if found), undefined (if API returns 404), or an error string
|
||||
*/
|
||||
search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => {
|
||||
const params = new URLSearchParams()
|
||||
if (query.continentId) params.append("continentId", query.continentId)
|
||||
if (query.region) params.append("region", query.region)
|
||||
params.append("remoteWork", query.remoteWork)
|
||||
if (query.text) params.append("text", query.text)
|
||||
return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`),
|
||||
reqInit("GET", user)), "searching job listings")
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing job listing
|
||||
*
|
||||
* @param listing The profile data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the update was successful, an error string if not
|
||||
*/
|
||||
update: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl(`listing/${listing.id}`), reqInit("PUT", user, listing)), "updating job listing")
|
||||
},
|
||||
|
||||
/** API functions for profiles */
|
||||
profile: {
|
||||
|
||||
/**
|
||||
* Clear the "seeking employment" flag on the current citizen's profile
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the action was successful, or an error string if not
|
||||
*/
|
||||
markEmploymentFound: async (user : LogOnSuccess) : Promise<boolean | string> => {
|
||||
const result = await fetch(apiUrl("profile/employment-found"), reqInit("PATCH", user))
|
||||
if (result.ok) return true
|
||||
return `${result.status} - ${result.statusText} (${await result.text()})`
|
||||
},
|
||||
|
||||
/**
|
||||
* Search for public profile data using the given parameters
|
||||
*
|
||||
* @param query The public profile search parameters
|
||||
* @returns The matching public profiles (if found), undefined (if API returns 404), or an error string
|
||||
*/
|
||||
publicSearch: async (query : PublicSearch) : Promise<PublicSearchResult[] | string | undefined> => {
|
||||
const params = new URLSearchParams()
|
||||
if (query.continentId) params.append("continentId", query.continentId)
|
||||
if (query.region) params.append("region", query.region)
|
||||
if (query.skill) params.append("skill", query.skill)
|
||||
params.append("remoteWork", query.remoteWork)
|
||||
return apiResult<PublicSearchResult[]>(
|
||||
await fetch(apiUrl(`profile/public-search?${params.toString()}`), { method: "GET" }),
|
||||
"searching public profile data")
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a profile
|
||||
*
|
||||
* @param id The ID of the profile to retrieve (optional; if omitted, retrieve for the current citizen)
|
||||
* @param user The currently logged-on user
|
||||
* @returns The profile (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreive: async (id : string | undefined, user : LogOnSuccess) : Promise<Profile | undefined | string> => {
|
||||
const url = id ? `profile/${id}` : "profile"
|
||||
const resp = await fetch(apiUrl(url), reqInit("GET", user))
|
||||
if (resp.status === 200) return await resp.json() as Profile
|
||||
if (resp.status !== 204) return `Error retrieving profile - ${await resp.text()}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a profile for viewing
|
||||
*
|
||||
* @param id The ID of the profile to retrieve for viewing
|
||||
* @param user The currently logged-on user
|
||||
* @returns The profile (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
|
||||
apiResult<ProfileForView>(await fetch(apiUrl(`profile/${id}/view`), reqInit("GET", user)), "retrieving profile"),
|
||||
|
||||
/**
|
||||
* Save a user's profile data
|
||||
*
|
||||
* @param data The profile data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the save was successful, an error string if not
|
||||
*/
|
||||
save: async (data : ProfileForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl("profile"), reqInit("POST", user, data)), "saving profile"),
|
||||
|
||||
/**
|
||||
* Search for profiles using the given parameters
|
||||
*
|
||||
* @param query The profile search parameters
|
||||
* @param user The currently logged-on user
|
||||
* @returns The matching profiles (if found), undefined (if API returns 404), or an error string
|
||||
*/
|
||||
search: async (query : ProfileSearch, user : LogOnSuccess) : Promise<ProfileSearchResult[] | string | undefined> => {
|
||||
const params = new URLSearchParams()
|
||||
if (query.continentId) params.append("continentId", query.continentId)
|
||||
if (query.skill) params.append("skill", query.skill)
|
||||
if (query.bioExperience) params.append("bioExperience", query.bioExperience)
|
||||
params.append("remoteWork", query.remoteWork)
|
||||
return apiResult<ProfileSearchResult[]>(await fetch(apiUrl(`profile/search?${params.toString()}`),
|
||||
reqInit("GET", user)), "searching profiles")
|
||||
},
|
||||
|
||||
/**
|
||||
* Count profiles in the system
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns A count of profiles within the entire system
|
||||
*/
|
||||
count: async (user : LogOnSuccess) : Promise<number | string> => {
|
||||
const resp = await fetch(apiUrl("profile/count"), reqInit("GET", user))
|
||||
if (resp.status === 200) {
|
||||
const result = await resp.json() as Count
|
||||
return result.count
|
||||
}
|
||||
return `Error counting profiles - ${await resp.text()}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the current user's employment profile
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns Undefined if successful, an error if not
|
||||
*/
|
||||
delete: async (user : LogOnSuccess) : Promise<string | undefined> =>
|
||||
apiAction(await fetch(apiUrl("profile"), reqInit("DELETE", user)), "deleting profile")
|
||||
},
|
||||
|
||||
/** API functions for success stories */
|
||||
success: {
|
||||
|
||||
/**
|
||||
* Retrieve all success stories
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns All success stories (if any exist), undefined (if none exist), or an error
|
||||
*/
|
||||
list: async (user : LogOnSuccess) : Promise<StoryEntry[] | string | undefined> =>
|
||||
apiResult<StoryEntry[]>(await fetch(apiUrl("successes"), reqInit("GET", user)), "retrieving success stories"),
|
||||
|
||||
/**
|
||||
* Retrieve a success story by its ID
|
||||
*
|
||||
* @param id The success story ID to be retrieved
|
||||
* @param user The currently logged-on user
|
||||
* @returns The success story, or an error
|
||||
*/
|
||||
retrieve: async (id : string, user : LogOnSuccess) : Promise<Success | string | undefined> =>
|
||||
apiResult<Success>(await fetch(apiUrl(`success/${id}`), reqInit("GET", user)), `retrieving success story ${id}`),
|
||||
|
||||
/**
|
||||
* Save a success story
|
||||
*
|
||||
* @param data The data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if successful, an error string if not
|
||||
*/
|
||||
save: async (data : StoryForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl("success"), reqInit("POST", user, data)), "saving success story")
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
@@ -1,298 +0,0 @@
|
||||
|
||||
/** A user of Jobs, Jobs, Jobs */
|
||||
export interface Citizen {
|
||||
/** The ID of the user */
|
||||
id : string
|
||||
/** The abbreviation of the instance where this citizen is based */
|
||||
instance : string
|
||||
/** The handle by which the user is known on Mastodon */
|
||||
mastodonUser : string
|
||||
/** The user's display name from Mastodon (updated every login) */
|
||||
displayName : string | undefined
|
||||
/** The user's real name */
|
||||
realName : string | undefined
|
||||
/** The URL for the user's Mastodon profile */
|
||||
profileUrl : string
|
||||
/** When the user joined Jobs, Jobs, Jobs (date) */
|
||||
joinedOn : string
|
||||
/** When the user last logged in (date) */
|
||||
lastSeenOn : string
|
||||
}
|
||||
|
||||
/** A continent */
|
||||
export interface Continent {
|
||||
/** The ID of the continent */
|
||||
id : string
|
||||
/** The name of the continent */
|
||||
name : string
|
||||
}
|
||||
|
||||
/** A count */
|
||||
export interface Count {
|
||||
/** The count being returned */
|
||||
count : number
|
||||
}
|
||||
|
||||
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
|
||||
export interface Instance {
|
||||
/** The name of the instance */
|
||||
name : string
|
||||
/** The URL for this instance */
|
||||
url : string
|
||||
/** The abbreviation used in the URL to distinguish this instance's return codes */
|
||||
abbr : string
|
||||
/** The client ID (assigned by the Mastodon server) */
|
||||
clientId : string
|
||||
/** Whether this instance is enabled */
|
||||
isEnabled : boolean
|
||||
/** If disabled, the reason why it is disabled */
|
||||
reason : string
|
||||
}
|
||||
|
||||
/** A job listing */
|
||||
export interface Listing {
|
||||
/** The ID of the job listing */
|
||||
id : string
|
||||
/** The ID of the citizen who posted the job listing */
|
||||
citizenId : string
|
||||
/** When this job listing was created (date) */
|
||||
createdOn : string
|
||||
/** The short title of the job listing */
|
||||
title : string
|
||||
/** The ID of the continent on which the job is located */
|
||||
continentId : string
|
||||
/** The region in which the job is located */
|
||||
region : string
|
||||
/** Whether this listing is for remote work */
|
||||
remoteWork : boolean
|
||||
/** Whether this listing has expired */
|
||||
isExpired : boolean
|
||||
/** When this listing was last updated (date) */
|
||||
updatedOn : string
|
||||
/** The details of this job */
|
||||
text : string
|
||||
/** When this job needs to be filled (date) */
|
||||
neededBy : string | undefined
|
||||
/** Was this job filled as part of its appearance on Jobs, Jobs, Jobs? */
|
||||
wasFilledHere : boolean | undefined
|
||||
}
|
||||
|
||||
/** The data required to add or edit a job listing */
|
||||
export class ListingForm {
|
||||
/** The ID of the listing */
|
||||
id = ""
|
||||
/** The listing title */
|
||||
title = ""
|
||||
/** The ID of the continent on which this opportunity exists */
|
||||
continentId = ""
|
||||
/** The region in which this opportunity exists */
|
||||
region = ""
|
||||
/** Whether this is a remote work opportunity */
|
||||
remoteWork = false
|
||||
/** The text of the job listing */
|
||||
text = ""
|
||||
/** The date by which this job listing is needed */
|
||||
neededBy : string | undefined
|
||||
}
|
||||
|
||||
/** The form submitted to expire a listing */
|
||||
export class ListingExpireForm {
|
||||
/** Whether the job was filled from here */
|
||||
fromHere = false
|
||||
/** The success story written by the user */
|
||||
successStory : string | undefined
|
||||
}
|
||||
|
||||
/** The data required to view a listing */
|
||||
export interface ListingForView {
|
||||
/** The listing itself */
|
||||
listing : Listing
|
||||
/** The continent for the listing */
|
||||
continent : Continent
|
||||
}
|
||||
|
||||
/** The various ways job listings can be searched */
|
||||
export interface ListingSearch {
|
||||
/** Retrieve opportunities from this continent */
|
||||
continentId : string | undefined
|
||||
/** Text for a search for a specific region */
|
||||
region : string | undefined
|
||||
/** Whether to retrieve job listings for remote work */
|
||||
remoteWork : string
|
||||
/** Text to search with a job's full description */
|
||||
text : string | undefined
|
||||
}
|
||||
|
||||
/** A successful logon */
|
||||
export interface LogOnSuccess {
|
||||
/** The JSON Web Token (JWT) to use for API access */
|
||||
jwt : string
|
||||
/** The ID of the logged-in citizen (as a string) */
|
||||
citizenId : string
|
||||
/** The name of the logged-in citizen */
|
||||
name : string
|
||||
}
|
||||
|
||||
/** A skill the job seeker possesses */
|
||||
export interface Skill {
|
||||
/** The ID of the skill */
|
||||
id : string
|
||||
/** A description of the skill */
|
||||
description : string
|
||||
/** Notes regarding this skill (level, duration, etc.) */
|
||||
notes : string | undefined
|
||||
}
|
||||
|
||||
/** A job seeker profile */
|
||||
export interface Profile {
|
||||
/** The ID of the citizen to whom this profile belongs */
|
||||
id : string
|
||||
/** Whether this citizen is actively seeking employment */
|
||||
seekingEmployment : boolean
|
||||
/** Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data */
|
||||
isPublic : boolean
|
||||
/** The ID of the continent on which the citizen resides */
|
||||
continentId : string
|
||||
/** The region in which the citizen resides */
|
||||
region : string
|
||||
/** Whether the citizen is looking for remote work */
|
||||
remoteWork : boolean
|
||||
/** Whether the citizen is looking for full-time work */
|
||||
fullTime : boolean
|
||||
/** The citizen's professional biography */
|
||||
biography : string
|
||||
/** When the citizen last updated their profile (date) */
|
||||
lastUpdatedOn : string
|
||||
/** The citizen's experience (topical / chronological) */
|
||||
experience : string | undefined
|
||||
/** Skills this citizen possesses */
|
||||
skills : Skill[]
|
||||
}
|
||||
|
||||
/** The data required to update a profile */
|
||||
export class ProfileForm {
|
||||
/** Whether the citizen to whom this profile belongs is actively seeking employment */
|
||||
isSeekingEmployment = false
|
||||
/** Whether this profile should appear in the public search */
|
||||
isPublic = false
|
||||
/** The user's real name */
|
||||
realName = ""
|
||||
/** The ID of the continent on which the citizen is located */
|
||||
continentId = ""
|
||||
/** The area within that continent where the citizen is located */
|
||||
region = ""
|
||||
/** If the citizen is available for remote work */
|
||||
remoteWork = false
|
||||
/** If the citizen is seeking full-time employment */
|
||||
fullTime = false
|
||||
/** The user's professional biography */
|
||||
biography = ""
|
||||
/** The user's past experience */
|
||||
experience : string | undefined
|
||||
/** The skills for the user */
|
||||
skills : Skill[] = []
|
||||
}
|
||||
|
||||
/** The data required to show a viewable profile */
|
||||
export interface ProfileForView {
|
||||
/** The profile itself */
|
||||
profile : Profile
|
||||
/** The citizen to whom the profile belongs */
|
||||
citizen : Citizen
|
||||
/** The continent for the profile */
|
||||
continent : Continent
|
||||
}
|
||||
|
||||
/** The various ways profiles can be searched */
|
||||
export interface ProfileSearch {
|
||||
/** Retrieve citizens from this continent */
|
||||
continentId : string | undefined
|
||||
/** Text for a search within a citizen's skills */
|
||||
skill : string | undefined
|
||||
/** Text for a search with a citizen's professional biography and experience fields */
|
||||
bioExperience : string | undefined
|
||||
/** Whether to retrieve citizens who do or do not want remote work */
|
||||
remoteWork : string
|
||||
}
|
||||
|
||||
/** A user matching the profile search */
|
||||
export interface ProfileSearchResult {
|
||||
/** The ID of the citizen */
|
||||
citizenId : string
|
||||
/** The citizen's display name */
|
||||
displayName : string
|
||||
/** Whether this citizen is currently seeking employment */
|
||||
seekingEmployment : boolean
|
||||
/** Whether this citizen is looking for remote work */
|
||||
remoteWork : boolean
|
||||
/** Whether this citizen is looking for full-time work */
|
||||
fullTime : boolean
|
||||
/** When this profile was last updated (date) */
|
||||
lastUpdatedOn : string
|
||||
}
|
||||
|
||||
/** The parameters for a public job search */
|
||||
export interface PublicSearch {
|
||||
/** Retrieve citizens from this continent */
|
||||
continentId : string | undefined
|
||||
/** Retrieve citizens from this region */
|
||||
region : string | undefined
|
||||
/** Text for a search within a citizen's skills */
|
||||
skill : string | undefined
|
||||
/** Whether to retrieve citizens who do or do not want remote work */
|
||||
remoteWork : string
|
||||
}
|
||||
|
||||
/** A public profile search result */
|
||||
export interface PublicSearchResult {
|
||||
/** The name of the continent on which the citizen resides */
|
||||
continent : string
|
||||
/** The region in which the citizen resides */
|
||||
region : string
|
||||
/** Whether this citizen is seeking remote work */
|
||||
remoteWork : boolean
|
||||
/** The skills this citizen has identified */
|
||||
skills : string[]
|
||||
}
|
||||
|
||||
/** An entry in the list of success stories */
|
||||
export interface StoryEntry {
|
||||
/** The ID of this success story */
|
||||
id : string
|
||||
/** The ID of the citizen who recorded this story */
|
||||
citizenId : string
|
||||
/** The name of the citizen who recorded this story */
|
||||
citizenName : string
|
||||
/** When this story was recorded (date) */
|
||||
recordedOn : string
|
||||
/** Whether this story involves an opportunity that arose due to Jobs, Jobs, Jobs */
|
||||
fromHere : boolean
|
||||
/** Whether this report has a further story, or if it is simply a "found work" entry */
|
||||
hasStory : boolean
|
||||
}
|
||||
|
||||
/** The data required to provide a success story */
|
||||
export class StoryForm {
|
||||
/** The ID of this story */
|
||||
id = ""
|
||||
/** Whether the employment was obtained from Jobs, Jobs, Jobs */
|
||||
fromHere = false
|
||||
/** The success story */
|
||||
story = ""
|
||||
}
|
||||
|
||||
/** A record of success finding employment */
|
||||
export interface Success {
|
||||
/** The ID of the success report */
|
||||
id : string
|
||||
/** The ID of the citizen who wrote this success report */
|
||||
citizenId : string
|
||||
/** When this success report was recorded (date) */
|
||||
recordedOn : string
|
||||
/** Whether the success was due, at least in part, to Jobs, Jobs, Jobs */
|
||||
fromHere : boolean
|
||||
/** The source of this success (listing or profile) */
|
||||
source : string
|
||||
/** The success story */
|
||||
story : string | undefined
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<template lang="pug">
|
||||
span(@click="playFile") #[slot] #[audio(:id="clip"): source(:src="clipSource")]
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
clip: string
|
||||
}>()
|
||||
|
||||
/** The full relative URL for the audio clip */
|
||||
const clipSource = `/audio/${props.clip}.mp3`
|
||||
|
||||
/** Play the audio file */
|
||||
const playFile = () => {
|
||||
const audio = document.getElementById(props.clip) as HTMLAudioElement
|
||||
audio.play()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
audio
|
||||
display: none
|
||||
span
|
||||
border-bottom: dotted 1px lightgray
|
||||
&:hover
|
||||
cursor: pointer
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<template lang="pug">
|
||||
.card: .card-body
|
||||
h6.card-title
|
||||
a(href="#" :class="{ 'cp-c': collapsed, 'cp-o': !collapsed }" @click.prevent="toggle") {{headerText}}
|
||||
slot(v-if="!collapsed")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
headerText: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
headerText: "Toggle",
|
||||
collapsed: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle") : void
|
||||
}>()
|
||||
|
||||
/** Emit the toggle event */
|
||||
const toggle = () => emit("toggle", !props.collapsed)
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
a.cp-c,
|
||||
a.cp-o
|
||||
text-decoration: none
|
||||
font-weight: bold
|
||||
color: black
|
||||
a.cp-c:hover,
|
||||
a.cp-o:hover
|
||||
cursor: pointer
|
||||
.cp-c::before
|
||||
content: '\2b9e \00a0'
|
||||
.cp-o::before
|
||||
content: '\2b9f \00a0'
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template lang="pug">
|
||||
.form-floating
|
||||
select.form-select(id="continentId" :class="{ 'is-invalid': isInvalid}" :value="continentId"
|
||||
@change="continentChanged")
|
||||
option(value="") – {{emptyLabel}} –
|
||||
option(v-for="c in continents" :key="c.id" :value="c.id") {{c.name}}
|
||||
label.jjj-required(for="continentId") Continent
|
||||
.invalid-feedback Please select a continent
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from "@/store"
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
topLabel?: string
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isInvalid: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value : string) : void
|
||||
(e: "touch") : void
|
||||
}>()
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The continent ID, which this component can change */
|
||||
const continentId = ref(props.modelValue)
|
||||
|
||||
/**
|
||||
* Mark the continent field as changed
|
||||
*
|
||||
* (This works around a really strange sequence where, if the "touch" call is directly wired up to the onChange event,
|
||||
* the first time a value is selected, it doesn't stick (although the field is marked as touched). On second and
|
||||
* subsequent times, it worked. The solution here is to grab the value and update the reactive source for the form, then
|
||||
* manually set the field to touched; this restores the expected behavior. This is probably why the library doesn't hook
|
||||
* into the onChange event to begin with...)
|
||||
*/
|
||||
const continentChanged = (e : Event) : boolean => {
|
||||
continentId.value = (e.target as HTMLSelectElement).value
|
||||
emit("touch")
|
||||
emit("update:modelValue", continentId.value)
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => await store.dispatch("ensureContinents"))
|
||||
|
||||
/** Accessor for the continent list */
|
||||
const continents = computed(() => store.state.continents)
|
||||
|
||||
/** The label to use for the top entry in the list */
|
||||
const emptyLabel = props.topLabel ?? "Select"
|
||||
</script>
|
||||
@@ -1,17 +0,0 @@
|
||||
<template lang="pug">
|
||||
template(v-if="errors.length > 0")
|
||||
p The following error#[template(v-if="errors.length !== 1") s] occurred:
|
||||
ul: li(v-for="(error, idx) in errors" :key="idx") {{error}}
|
||||
slot(v-else)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
errors: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
ul li
|
||||
font-family: monospace
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template lang="pug">
|
||||
template(v-if="true") {{formatted}}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format } from "date-fns"
|
||||
import { parseToUtc } from "./"
|
||||
|
||||
const props = defineProps<{
|
||||
date: string
|
||||
}>()
|
||||
|
||||
/** The formatted date */
|
||||
const formatted = format(parseToUtc(props.date), "PPP")
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template lang="pug">
|
||||
template(v-if="true") {{formatted}}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format } from "date-fns"
|
||||
import { parseToUtc } from "./"
|
||||
|
||||
const props = defineProps<{
|
||||
date: string
|
||||
}>()
|
||||
|
||||
/** The formatted date/time */
|
||||
const formatted = format(parseToUtc(props.date), "PPPp")
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template lang="pug">
|
||||
svg(viewbox="0 0 24 24"): path(:fill="color || 'white'" :d="icon")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
color?: string
|
||||
icon: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
svg
|
||||
width: 24px
|
||||
height: 24px
|
||||
</style>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template lang="pug">
|
||||
form.container
|
||||
.row
|
||||
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||
.col-xs-12.col-sm-6.col-lg-3
|
||||
.form-floating
|
||||
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
|
||||
@input="updateValue('region', $event.target.value)")
|
||||
label(for="regionSearch") Region
|
||||
.form-text (free-form text)
|
||||
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||
label.jjj-label Remote Work Opportunity?
|
||||
br
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||
@click="updateValue('remoteWork', '')")
|
||||
label.form-check-label(for="remoteNull") No Selection
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||
@click="updateValue('remoteWork', 'yes')")
|
||||
label.form-check-label(for="remoteYes") Yes
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||
@click="updateValue('remoteWork', 'no')")
|
||||
label.form-check-label(for="remoteNo") No
|
||||
.col-xs-12.col-sm-6.col-lg-3
|
||||
.form-floating
|
||||
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
|
||||
@input="updateValue('text', $event.target.value)")
|
||||
label(for="textSearch") Job Listing Text
|
||||
.form-text (free-form text)
|
||||
.row: .col
|
||||
br
|
||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ListingSearch } from "@/api"
|
||||
import { ref } from "vue"
|
||||
import ContinentList from "./ContinentList.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ListingSearch
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "search") : void
|
||||
(e: "update:modelValue", value : ListingSearch) : void
|
||||
}>()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria = ref({ ...props.modelValue })
|
||||
|
||||
/** Emit a value update */
|
||||
const updateValue = (key : string, value : string) => {
|
||||
criteria.value = { ...criteria.value, [key]: value }
|
||||
emit("update:modelValue", criteria.value)
|
||||
}
|
||||
|
||||
/** Update the continent ID */
|
||||
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template lang="pug">
|
||||
div(v-if="loading") Loading…
|
||||
error-list(v-else :errors="errors")
|
||||
slot
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue"
|
||||
import ErrorList from "./ErrorList.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
load: (errors : string[]) => Promise<unknown>
|
||||
}>()
|
||||
|
||||
/** Errors encountered during loading */
|
||||
const errors : string[] = []
|
||||
|
||||
/** Whether we are currently loading data */
|
||||
const loading = ref(true)
|
||||
|
||||
/** Call the data load function */
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await props.load(errors)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template lang="pug">
|
||||
.col-12
|
||||
nav.nav.nav-pills.pb-1
|
||||
button(:class="sourceClass" @click.prevent="showMarkdown") Markdown
|
||||
|
|
||||
button(:class="previewClass" @click.prevent="showPreview") Preview
|
||||
section.preview(v-if="preview" v-html="previewHtml")
|
||||
.form-floating(v-else)
|
||||
textarea.form-control.md-edit(:id="id" :class="{ 'is-invalid': isInvalid }" rows="10" v-text="text"
|
||||
@input="$emit('update:text', $event.target.value)")
|
||||
.invalid-feedback Please enter some text for {{label}}
|
||||
label(:for="id") {{label}}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { toHtml } from "@/markdown"
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
text: string
|
||||
label: string
|
||||
isInvalid?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:text", value : string) : void
|
||||
}>()
|
||||
|
||||
/** Whether to show the Markdown preview */
|
||||
const preview = ref(false)
|
||||
|
||||
/** The HTML rendered for preview purposes */
|
||||
const previewHtml = ref("")
|
||||
|
||||
/** Show the Markdown source */
|
||||
const showMarkdown = () => {
|
||||
preview.value = false
|
||||
}
|
||||
|
||||
/** Show the Markdown preview */
|
||||
const showPreview = () => {
|
||||
previewHtml.value = toHtml(props.text)
|
||||
preview.value = true
|
||||
}
|
||||
|
||||
/** Button classes for the selected button */
|
||||
const selected = "btn btn-primary btn-sm rounded-pill"
|
||||
|
||||
/** Button classes for the unselected button */
|
||||
const unselected = "btn btn-outline-secondary btn-sm rounded-pill"
|
||||
|
||||
/** The CSS class for the Markdown source button */
|
||||
const sourceClass = computed(() => preview.value ? unselected : selected)
|
||||
|
||||
/** The CSS class for the Markdown preview button */
|
||||
const previewClass = computed(() => preview.value ? selected : unselected)
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.md-edit
|
||||
width: 100%
|
||||
// When wrapping this with Bootstrap's floating label, it shrinks the input down to what a normal one-line input
|
||||
// would be; this overrides that for the textarea in this component specifically
|
||||
height: inherit !important
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template lang="pug">
|
||||
.modal.fade(id="maybeSaveModal" tabindex="-1" aria-labelledby="maybeSaveLabel" aria-hidden="true"): .modal-dialog: .modal-content
|
||||
.modal-header: h5.modal-title(id="maybeSaveLabel") Unsaved Changes
|
||||
.modal-body You have modified the data on this page since it was last saved. What would you like to do?
|
||||
.modal-footer
|
||||
button.btn.btn-secondary(type="button" @click.prevent="close") Stay on This Page
|
||||
button.btn.btn-primary(type="button" @click.prevent="save") Save Changes
|
||||
button.btn.btn-danger(type="button" @click.prevent="discard") Discard Changes
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, Ref } from "vue"
|
||||
import { onBeforeRouteLeave, RouteLocationNormalized, useRouter } from "vue-router"
|
||||
import { Validation } from "@vuelidate/core"
|
||||
import { Modal } from "bootstrap"
|
||||
|
||||
const props = defineProps<{
|
||||
saveAction: () => Promise<unknown>
|
||||
validator?: Validation
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** Reference to the modal dialog (we can't get it until the component is rendered) */
|
||||
const modal : Ref<Modal | undefined> = ref(undefined)
|
||||
|
||||
/** The route to which navigation was intercepted, and will be resumed */
|
||||
let nextRoute : RouteLocationNormalized
|
||||
|
||||
/** Close the modal window */
|
||||
const close = () => modal.value?.hide()
|
||||
|
||||
/** Save changes and go to the next route */
|
||||
const save = async () => {
|
||||
await props.saveAction()
|
||||
close()
|
||||
router.push(nextRoute)
|
||||
}
|
||||
|
||||
/** Discard changes and go to the next route */
|
||||
const discard = () => {
|
||||
if (props.validator) props.validator.$reset()
|
||||
close()
|
||||
router.push(nextRoute)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
modal.value = new Modal(document.getElementById("maybeSaveModal") as HTMLElement,
|
||||
{ backdrop: "static", keyboard: false })
|
||||
})
|
||||
|
||||
/** Prompt for save if the user navigates away with unsaved changes */
|
||||
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
|
||||
if (!props.validator || !props.validator.$anyDirty) return true
|
||||
nextRoute = to
|
||||
modal.value?.show()
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
import { parseJSON } from "date-fns"
|
||||
import { utcToZonedTime } from "date-fns-tz"
|
||||
|
||||
/**
|
||||
* Parse a date from its JSON representation to a UTC-aligned date
|
||||
*
|
||||
* @param date The date string in JSON from JSON
|
||||
* @returns A UTC JavaScript date
|
||||
*/
|
||||
export function parseToUtc (date : string) : Date {
|
||||
return utcToZonedTime(parseJSON(date), Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<template lang="pug">
|
||||
footer: p.text-muted.
|
||||
Jobs, Jobs, Jobs v{{appVersion}} • #[router-link(to="/privacy-policy") Privacy Policy]
|
||||
• #[router-link(to="/terms-of-service") Terms of Service]
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { version } from "../../../package.json"
|
||||
|
||||
let appVersion : string = version
|
||||
while (appVersion.endsWith(".0")) {
|
||||
appVersion = appVersion.substring(0, appVersion.length - 2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
footer
|
||||
display: flex
|
||||
flex-direction: row-reverse
|
||||
p
|
||||
padding-top: 2rem
|
||||
padding-right: .5rem
|
||||
font-style: italic
|
||||
font-size: .8rem
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template lang="pug">
|
||||
nav
|
||||
template(v-if="isLoggedOn")
|
||||
router-link(to="/citizen/dashboard" @click="hide") #[icon(:icon="mdiViewDashboardVariant")] Dashboard
|
||||
router-link(to="/help-wanted" @click="hide") #[icon(:icon="mdiNewspaperVariantMultipleOutline")] Help Wanted!
|
||||
router-link(to="/profile/search" @click="hide") #[icon(:icon="mdiViewListOutline")] Employment Profiles
|
||||
router-link(to="/success-story/list" @click="hide") #[icon(:icon="mdiThumbUp")] Success Stories
|
||||
.separator
|
||||
router-link(to="/listings/mine" @click="hide") #[icon(:icon="mdiSignText")] My Job Listings
|
||||
router-link(to="/citizen/profile" @click="hide") #[icon(:icon="mdiPencil")] My Employment Profile
|
||||
.separator
|
||||
router-link(to="/citizen/log-off" @click="hide") #[icon(:icon="mdiLogoutVariant")] Log Off
|
||||
template(v-else)
|
||||
router-link(to="/" @click="hide") #[icon(:icon="mdiHome")] Home
|
||||
router-link(to="/profile/seeking" @click="hide") #[icon(:icon="mdiViewListOutline")] Job Seekers
|
||||
router-link(to="/citizen/log-on" @click="hide") #[icon(:icon="mdiLoginVariant")] Log On
|
||||
router-link(to="/how-it-works" @click="hide") #[icon(:icon="mdiHelpCircleOutline")] How It Works
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Offcanvas } from "bootstrap"
|
||||
import { useStore } from "@/store"
|
||||
import {
|
||||
mdiHelpCircleOutline,
|
||||
mdiHome,
|
||||
mdiLoginVariant,
|
||||
mdiLogoutVariant,
|
||||
mdiNewspaperVariantMultipleOutline,
|
||||
mdiPencil,
|
||||
mdiSignText,
|
||||
mdiThumbUp,
|
||||
mdiViewDashboardVariant,
|
||||
mdiViewListOutline
|
||||
} from "@mdi/js"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** Whether a user is logged in or not */
|
||||
const isLoggedOn = computed(() => store.state.user !== undefined)
|
||||
|
||||
/** The current mobile menu */
|
||||
const menu = computed(() => {
|
||||
const elt = document.getElementById("mobileMenu")
|
||||
return elt ? Offcanvas.getOrCreateInstance(elt) : undefined
|
||||
})
|
||||
|
||||
/** Hide the offcanvas menu (if it exists) when a link is clicked */
|
||||
const hide = () => { if (menu.value) menu.value.hide() }
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
path
|
||||
fill: white
|
||||
path:hover
|
||||
fill: black
|
||||
a:link, a:visited
|
||||
text-decoration: none
|
||||
color: white
|
||||
nav > a
|
||||
display: block
|
||||
width: 100%
|
||||
border-radius: .25rem
|
||||
padding: .5rem
|
||||
margin: .5rem 0
|
||||
font-size: 1rem
|
||||
> i
|
||||
vertical-align: top
|
||||
margin-right: 1rem
|
||||
&.router-link-exact-active
|
||||
background-color: rgba(255, 255, 255, .2)
|
||||
&:hover
|
||||
background-color: rgba(255, 255, 255, .5)
|
||||
color: black
|
||||
text-decoration: none
|
||||
nav > div.separator
|
||||
border-bottom: solid 1px rgba(255, 255, 255, .75)
|
||||
height: 1px
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template lang="pug">
|
||||
#mobileMenu.offcanvas.offcanvas-end(v-if="showMobileMenu" tabindex="-1" aria-labelledby="mobileMenuLabel")
|
||||
.offcanvas-header
|
||||
h5#mobileMenuLabel Menu
|
||||
button.btn-close.text-reset(type="button" data-bs-dismiss="offcanvas" aria-label="Close")
|
||||
.offcanvas-body: app-links
|
||||
aside.collapse.show.p-3(v-else)
|
||||
p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
|
||||
p
|
||||
app-links
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||
import AppLinks from "./AppLinks.vue"
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||
|
||||
/** Whether the mobile menu or the desktop menu should be shown */
|
||||
const showMobileMenu = breakpoints.smaller("md")
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
aside,
|
||||
#mobileMenu
|
||||
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
|
||||
color: white
|
||||
font-size: 1.2rem
|
||||
aside
|
||||
min-height: 100vh
|
||||
width: 250px
|
||||
min-width: 250px
|
||||
position: sticky
|
||||
top: 0
|
||||
.home-link
|
||||
font-size: 1.2rem
|
||||
text-align: center
|
||||
background-color: rgba(0, 0, 0, .4)
|
||||
margin: -1rem
|
||||
padding: 1rem
|
||||
a:link,
|
||||
a:visited
|
||||
text-decoration: none
|
||||
color: white
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template lang="pug">
|
||||
div(aria-live="polite" aria-atomic="true" id="toastHost")
|
||||
.toast-container.position-absolute.p-3.bottom-0.start-50.translate-middle-x(id="toasts")
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { Toast } from "bootstrap"
|
||||
|
||||
/** Remove a toast once it's hidden */
|
||||
const removeToast = (event : Event) => (event.target as HTMLDivElement).remove()
|
||||
|
||||
/** Create a toast, add it to the DOM, and show it */
|
||||
const createToast = (level : "success" | "warning" | "danger", message : string, process : string | undefined) => {
|
||||
let header : HTMLDivElement | undefined
|
||||
if (level !== "success") {
|
||||
// Create a heading, optionally including the process that generated the message
|
||||
const heading = (typ : string) : string => {
|
||||
const proc = process ? ` (${process})` : ""
|
||||
return `<span class="me-auto"><strong>${typ.toUpperCase()}</strong>${proc}</span>`
|
||||
}
|
||||
header = document.createElement("div")
|
||||
header.className = "toast-header"
|
||||
header.innerHTML = heading(level === "warning" ? level : "error")
|
||||
// Include a close button, as these will not auto-close
|
||||
const close = document.createElement("button")
|
||||
close.type = "button"
|
||||
close.className = "btn-close"
|
||||
close.setAttribute("data-bs-dismiss", "toast")
|
||||
close.setAttribute("aria-label", "Close")
|
||||
header.appendChild(close)
|
||||
}
|
||||
const body = document.createElement("div")
|
||||
body.className = "toast-body"
|
||||
body.innerHTML = message
|
||||
|
||||
const toastEl = document.createElement("div")
|
||||
toastEl.className = `toast bg-${level} text-white`
|
||||
toastEl.setAttribute("role", "alert")
|
||||
toastEl.setAttribute("aria-live", "assertlive")
|
||||
toastEl.setAttribute("aria-atomic", "true")
|
||||
toastEl.addEventListener("hidden.bs.toast", removeToast)
|
||||
if (header) toastEl.appendChild(header)
|
||||
toastEl.appendChild(body)
|
||||
|
||||
;(document.getElementById("toasts") as HTMLDivElement).appendChild(toastEl)
|
||||
new Toast(toastEl, { autohide: level === "success" }).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a success toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
*/
|
||||
export function toastSuccess (message : string) : void {
|
||||
createToast("success", message, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a warning toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
* @param process The process which generated the warning (optional)
|
||||
*/
|
||||
export function toastWarning (message : string, process : string | undefined) : void {
|
||||
createToast("warning", message, process)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an error toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
* @param process The process which generated the error (optional)
|
||||
*/
|
||||
export function toastError (message : string, process : string | undefined) : void {
|
||||
createToast("danger", message, process)
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "AppToaster"
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
#toastHost
|
||||
position: sticky
|
||||
bottom: 0
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template lang="pug">
|
||||
nav.navbar.navbar-dark(v-if="showMobileHeader")
|
||||
span.navbar-text: router-link(to="/") Jobs, Jobs, Jobs
|
||||
button.btn(data-bs-toggle="offcanvas" data-bs-target="#mobileMenu" aria-controls="mobileMenu")
|
||||
icon(:icon="mdiMenu")
|
||||
nav.navbar.navbar-light.bg-light(v-else)
|
||||
span
|
||||
span.navbar-text.
|
||||
(…and Jobs – #[audio-clip(clip="pelosi-jobs") Let’s Vote for Jobs!])
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiMenu } from "@mdi/js"
|
||||
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||
|
||||
import AudioClip from "@/components/AudioClip.vue"
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||
|
||||
/** Whether to show the mobile or desktop header */
|
||||
const showMobileHeader = breakpoints.smaller("md")
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.navbar-dark
|
||||
background-image: linear-gradient(0deg, green 0%, darkgreen 70%)
|
||||
padding-left: 1rem
|
||||
padding-right: 1rem
|
||||
button
|
||||
padding: 0
|
||||
.navbar-text
|
||||
font-weight: bold
|
||||
color: white
|
||||
.navbar-light
|
||||
.navbar-text
|
||||
font-style: italic
|
||||
padding: 0 1rem 0 0
|
||||
</style>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template lang="pug">
|
||||
form.container
|
||||
.row
|
||||
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||
.form-floating
|
||||
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
|
||||
:value="criteria.region" @input="updateValue('region', $event.target.value)")
|
||||
label(for="region") Region
|
||||
.form-text (free-form text)
|
||||
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||
label.jjj-label Seeking Remote Work?
|
||||
br
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||
@click="updateValue('remoteWork', '')")
|
||||
label.form-check-label(for="remoteNull") No Selection
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||
@click="updateValue('remoteWork', 'yes')")
|
||||
label.form-check-label(for="remoteYes") Yes
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||
@click="updateValue('remoteWork', 'no')")
|
||||
label.form-check-label(for="remoteNo") No
|
||||
.col-xs-12.col-sm-6.col-lg-3
|
||||
.form-floating
|
||||
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
|
||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
|
||||
label(for="skillSearch") Skill
|
||||
.form-text (free-form text)
|
||||
.row: .col
|
||||
br
|
||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { PublicSearch } from "@/api"
|
||||
import ContinentList from "../ContinentList.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: PublicSearch
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "search") : void
|
||||
(e: "update:modelValue", value : PublicSearch) : void
|
||||
}>()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria = ref({ ...props.modelValue })
|
||||
|
||||
/** Emit a value update */
|
||||
const updateValue = (key : string, value : string) => {
|
||||
criteria.value = { ...criteria.value, [key]: value }
|
||||
emit("update:modelValue", criteria.value)
|
||||
}
|
||||
|
||||
/** Update the continent ID */
|
||||
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||
</script>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template lang="pug">
|
||||
form.container
|
||||
.row
|
||||
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||
label.jjj-label Seeking Remote Work?
|
||||
br
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||
@click="updateValue('remoteWork', '')")
|
||||
label.form-check-label(for="remoteNull") No Selection
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||
@click="updateValue('remoteWork', 'yes')")
|
||||
label.form-check-label(for="remoteYes") Yes
|
||||
.form-check.form-check-inline
|
||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||
@click="updateValue('remoteWork', 'no')")
|
||||
label.form-check-label(for="remoteNo") No
|
||||
.col-xs-12.col-sm-6.col-lg-3
|
||||
.form-floating
|
||||
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
|
||||
@input="updateValue('skill', $event.target.value)")
|
||||
label(for="skillSearch") Skill
|
||||
.form-text (free-form text)
|
||||
.col-xs-12.col-sm-6.col-lg-3
|
||||
.form-floating
|
||||
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
|
||||
@input="updateValue('bioExperience', $event.target.value)")
|
||||
label(for="bioSearch") Bio / Experience
|
||||
.form-text (free-form text)
|
||||
.row: .col
|
||||
br
|
||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { ProfileSearch } from "@/api"
|
||||
import ContinentList from "../ContinentList.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ProfileSearch
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "search") : void
|
||||
(e: "update:modelValue", value : ProfileSearch) : void
|
||||
}>()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria = ref({ ...props.modelValue })
|
||||
|
||||
/** Emit a value update */
|
||||
const updateValue = (key : string, value : string) => {
|
||||
criteria.value = { ...criteria.value, [key]: value }
|
||||
emit("update:modelValue", criteria.value)
|
||||
}
|
||||
|
||||
/** Update the continent ID */
|
||||
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||
</script>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template lang="pug">
|
||||
.row.pb-3
|
||||
.col-xs-2.col-md-1.align-self-center
|
||||
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") −
|
||||
.col-xs-10.col-md-6
|
||||
.form-floating
|
||||
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
|
||||
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
|
||||
@input="updateValue('description', $event.target.value)")
|
||||
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
|
||||
.form-text A skill (language, design technique, process, etc.)
|
||||
.col-xs-12.col-md-5
|
||||
.form-floating
|
||||
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
|
||||
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"
|
||||
@input="updateValue('notes', $event.target.value)")
|
||||
label.jjj-label(:for="`skillNotes${skill.id}`") Notes
|
||||
.form-text A further description of the skill (100 characters max)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from "vue"
|
||||
import { Skill } from "@/api"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Skill
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input") : void
|
||||
(e: "remove") : void
|
||||
(e: "update:modelValue", value: Skill) : void
|
||||
}>()
|
||||
|
||||
/** The skill being edited */
|
||||
const skill : Ref<Skill> = ref({ ...props.modelValue as Skill })
|
||||
|
||||
/** Update a value in the model */
|
||||
const updateValue = (key : string, value : string) => {
|
||||
skill.value = { ...skill.value, [key]: value }
|
||||
emit("update:modelValue", skill.value)
|
||||
emit("input")
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
import router from "./router"
|
||||
import store, { key } from "./store"
|
||||
import Icon from "./components/Icon.vue"
|
||||
|
||||
const app = createApp(App)
|
||||
.use(router)
|
||||
.use(store, key)
|
||||
|
||||
app.component("Icon", Icon)
|
||||
|
||||
app.mount("#app")
|
||||
@@ -1,12 +0,0 @@
|
||||
import { sanitize } from "dompurify"
|
||||
import { marked } from "marked"
|
||||
|
||||
/**
|
||||
* Transform Markdown to HTML (standardize option, sanitize the output)
|
||||
*
|
||||
* @param markdown The Markdown text to be rendered as HTML
|
||||
* @returns The rendered HTML
|
||||
*/
|
||||
export function toHtml (markdown : string) : string {
|
||||
return sanitize(marked(markdown, { gfm: true, smartypants: true }), { USE_PROFILES: { html: true } })
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
RouteLocationNormalized,
|
||||
RouteLocationNormalizedLoaded,
|
||||
RouteRecordRaw
|
||||
} from "vue-router"
|
||||
import store, { Mutations } from "@/store"
|
||||
import Home from "@/views/Home.vue"
|
||||
import LogOn from "@/views/citizen/LogOn.vue"
|
||||
|
||||
/** The URL to which the user should be pointed once they have authorized with Mastodon */
|
||||
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
|
||||
|
||||
/**
|
||||
* Get a value from the query string
|
||||
*
|
||||
* @param route The current route
|
||||
* @param key The key of the query string value to obtain
|
||||
* @returns The string value, the first of many (if included multiple times), or `undefined` if not present
|
||||
*/
|
||||
export function queryValue (route: RouteLocationNormalizedLoaded, key : string) : string | undefined {
|
||||
const value = route.query[key]
|
||||
if (value) return Array.isArray(value) && value.length > 0 ? value[0]?.toString() : value.toString()
|
||||
}
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: Home,
|
||||
meta: { title: "Welcome!" }
|
||||
},
|
||||
{
|
||||
path: "/how-it-works",
|
||||
name: "HowItWorks",
|
||||
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
|
||||
meta: { title: "How It Works" }
|
||||
},
|
||||
{
|
||||
path: "/privacy-policy",
|
||||
name: "PrivacyPolicy",
|
||||
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
|
||||
meta: { title: "Privacy Policy" }
|
||||
},
|
||||
{
|
||||
path: "/terms-of-service",
|
||||
name: "TermsOfService",
|
||||
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
|
||||
meta: { title: "Terms of Service" }
|
||||
},
|
||||
// Citizen URLs
|
||||
{
|
||||
path: "/citizen/log-on",
|
||||
name: "LogOn",
|
||||
component: LogOn,
|
||||
meta: { title: "Log On" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/:abbr/authorized",
|
||||
name: "CitizenAuthorized",
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue"),
|
||||
meta: { title: "Logging On" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/dashboard",
|
||||
name: "Dashboard",
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
|
||||
meta: { auth: true, title: "Dashboard" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/profile",
|
||||
name: "EditProfile",
|
||||
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue"),
|
||||
meta: { auth: true, title: "Edit Profile" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/log-off",
|
||||
name: "LogOff",
|
||||
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"),
|
||||
meta: { auth: true, title: "Logging Off" }
|
||||
},
|
||||
// Job Listing URLs
|
||||
{
|
||||
path: "/help-wanted",
|
||||
name: "HelpWanted",
|
||||
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..." }
|
||||
},
|
||||
{
|
||||
path: "/listings/mine",
|
||||
name: "MyListings",
|
||||
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue"),
|
||||
meta: { auth: true, title: "My Job Listings" }
|
||||
},
|
||||
// Profile URLs
|
||||
{
|
||||
path: "/profile/:id/view",
|
||||
name: "ViewProfile",
|
||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"),
|
||||
meta: { auth: true, title: "Loading Profile..." }
|
||||
},
|
||||
{
|
||||
path: "/profile/search",
|
||||
name: "SearchProfiles",
|
||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
|
||||
meta: { auth: true, title: "Search Profiles" }
|
||||
},
|
||||
{
|
||||
path: "/profile/seeking",
|
||||
name: "PublicSearchProfiles",
|
||||
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue"),
|
||||
meta: { auth: false, title: "People Seeking Work" }
|
||||
},
|
||||
// "So Long" URLs
|
||||
{
|
||||
path: "/so-long/options",
|
||||
name: "DeletionOptions",
|
||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue"),
|
||||
meta: { auth: true, title: "Account Deletion Options" }
|
||||
},
|
||||
{
|
||||
path: "/so-long/success/:abbr",
|
||||
name: "DeletionSuccess",
|
||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
|
||||
meta: { auth: false, title: "Account Deletion Success" }
|
||||
},
|
||||
// Success Story URLs
|
||||
{
|
||||
path: "/success-story/list",
|
||||
name: "ListStories",
|
||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue"),
|
||||
meta: { auth: false, title: "Success Stories" }
|
||||
},
|
||||
{
|
||||
path: "/success-story/:id/edit",
|
||||
name: "EditStory",
|
||||
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue"),
|
||||
meta: { auth: false, title: "Edit Success Story" }
|
||||
},
|
||||
{
|
||||
path: "/success-story/:id/view",
|
||||
name: "ViewStory",
|
||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue"),
|
||||
meta: { auth: false, title: "Success Story" }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
// eslint-disable-next-line
|
||||
scrollBehavior (to : RouteLocationNormalized, from : RouteLocationNormalizedLoaded, savedPosition : any) {
|
||||
return savedPosition ?? { top: 0, left: 0 }
|
||||
},
|
||||
routes
|
||||
})
|
||||
|
||||
// eslint-disable-next-line
|
||||
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
|
||||
if (store.state.user === undefined && (to.meta.auth || false)) {
|
||||
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
|
||||
return "/citizen/log-on"
|
||||
}
|
||||
store.commit(Mutations.SetTitle, to.meta.title ?? "")
|
||||
})
|
||||
|
||||
export default router
|
||||
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/* eslint-disable */
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue"
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** Logs a user on to Jobs, Jobs, Jobs */
|
||||
export const LogOn = "logOn"
|
||||
|
||||
/** Ensures that the continent list in the state has been populated */
|
||||
export const EnsureContinents = "ensureContinents"
|
||||
|
||||
/** Ensures that the Mastodon instance list in the state has been populated */
|
||||
export const EnsureInstances = "ensureInstances"
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useTitle } from "@vueuse/core"
|
||||
import { InjectionKey } from "vue"
|
||||
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
||||
import api, { Continent, Instance, LogOnSuccess } from "../api"
|
||||
import * as Actions from "./actions"
|
||||
import * as Mutations from "./mutations"
|
||||
|
||||
/** The state tracked by the application */
|
||||
export interface State {
|
||||
/** The document's current title */
|
||||
pageTitle : string
|
||||
/** The currently logged-on user */
|
||||
user : LogOnSuccess | undefined
|
||||
/** The state of the log on process */
|
||||
logOnState : string
|
||||
/** All continents (use `ensureContinents` action) */
|
||||
continents : Continent[]
|
||||
/** All instances (use `ensureInstances` action) */
|
||||
instances : Instance[]
|
||||
}
|
||||
|
||||
/** An injection key to identify this state with Vue */
|
||||
export const key : InjectionKey<Store<State>> = Symbol("VueX Store")
|
||||
|
||||
/** Use this store in component `setup` functions */
|
||||
export function useStore () : Store<State> {
|
||||
return baseUseStore(key)
|
||||
}
|
||||
|
||||
/** The application name */
|
||||
const appName = "Jobs, Jobs, Jobs"
|
||||
|
||||
export default createStore({
|
||||
state: () : State => {
|
||||
return {
|
||||
pageTitle: "",
|
||||
user: undefined,
|
||||
logOnState: "<em>Welcome back!</em>",
|
||||
continents: [],
|
||||
instances: []
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
[Mutations.SetTitle]: (state, title : string) => {
|
||||
state.pageTitle = title === "" ? appName : `${title} | ${appName}`
|
||||
useTitle(state.pageTitle)
|
||||
},
|
||||
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
|
||||
[Mutations.ClearUser]: (state) => { state.user = undefined },
|
||||
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
|
||||
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
|
||||
[Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
|
||||
},
|
||||
actions: {
|
||||
[Actions.LogOn]: async ({ commit }, { abbr, code }) => {
|
||||
const logOnResult = await api.citizen.logOn(abbr, code)
|
||||
if (typeof logOnResult === "string") {
|
||||
commit(Mutations.SetLogOnState, logOnResult)
|
||||
} else {
|
||||
commit(Mutations.SetUser, logOnResult)
|
||||
}
|
||||
},
|
||||
[Actions.EnsureContinents]: async ({ state, commit }) => {
|
||||
if (state.continents.length > 0) return
|
||||
const theSeven = await api.continent.all()
|
||||
if (typeof theSeven === "string") {
|
||||
console.error(theSeven)
|
||||
} else {
|
||||
commit(Mutations.SetContinents, theSeven)
|
||||
}
|
||||
},
|
||||
[Actions.EnsureInstances]: async ({ state, commit }) => {
|
||||
if (state.instances.length > 0) return
|
||||
const instResp = await api.instances.all()
|
||||
if (typeof instResp === "string") {
|
||||
console.error(instResp)
|
||||
} else if (typeof instResp === "undefined") {
|
||||
console.error("No instances were found; this should not happen")
|
||||
} else {
|
||||
commit(Mutations.SetInstances, instResp)
|
||||
}
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
|
||||
export * as Actions from "./actions"
|
||||
export * as Mutations from "./mutations"
|
||||
@@ -1,17 +0,0 @@
|
||||
/** Set the page title */
|
||||
export const SetTitle = "setTitle"
|
||||
|
||||
/** Set the logged-on user */
|
||||
export const SetUser = "setUser"
|
||||
|
||||
/** Clear the logged-on user */
|
||||
export const ClearUser = "clearUser"
|
||||
|
||||
/** Set the status of the current log on action */
|
||||
export const SetLogOnState = "setLogOnState"
|
||||
|
||||
/** Set the list of continents */
|
||||
export const SetContinents = "setContinents"
|
||||
|
||||
/** Set the list of Mastodon instances */
|
||||
export const SetInstances = "setInstances"
|
||||
@@ -1,18 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
p
|
||||
p.
|
||||
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in
|
||||
finding employment. This will enable them to continue providing value-for-value to Adam and John, as they continue
|
||||
their work deconstructing the misinformation that passes for news on a day-to-day basis.
|
||||
p
|
||||
| Do you not understand the terms in the paragraph above? No worries; just head over to
|
||||
|
|
||||
a(href="https://noagendashow.net" target="_blank") The Best Podcast in the Universe
|
||||
= " "
|
||||
| #[em #[audio-clip(clip="thats-true") (that’s true!)]] and find out what you’re missing.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AudioClip from "@/components/AudioClip.vue"
|
||||
</script>
|
||||
@@ -1,167 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3 How It Works
|
||||
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
|
||||
p: em.
|
||||
Show me how to #[a(href="#listing-search") find a job]
|
||||
#[!= " • "]#[a(href="#listing") list a job opportunity]
|
||||
#[!= " • "]#[a(href="#profile-search") find people to hire]
|
||||
#[!= " • "]#[a(href="#profile") create an employment profile]
|
||||
|
||||
hr
|
||||
|
||||
h4#listing-search Find a Job Listing
|
||||
p.
|
||||
Active job listings are found on the #[span.link Help Wanted!] page. When you first bring up this page, you will see
|
||||
several criteria by which you can narrow your results, though none are required. When you click the
|
||||
#[span.button Search] button, you will see open job listings filtered by whatever criteria you specified. Each job
|
||||
displays its title, its location, whether it is a remote opportunity, and (if specified) the date by which the job
|
||||
needs to be filled.
|
||||
p.
|
||||
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
|
||||
of the information from the search results, along with the citizen who posted it, and the full details of the job.
|
||||
The citizen’s name is a link to their profile page at their Mastodon instance; you can use that to get their
|
||||
handle, and use Mastodon’s communication facilites to inquire about the position.
|
||||
p: em.text-muted.
|
||||
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
|
||||
I’ve searched and searched, and asked NAS, but have not yet determined how to do that.)
|
||||
|
||||
hr
|
||||
|
||||
h4#listing Job Listings
|
||||
h5 Create a Job Listing
|
||||
p.
|
||||
The #[span.link My Job Listings] page shows all of the job listings you have created. To add a new one, click the
|
||||
#[span.button Add a New Listing] button. This page allows you to specify a title for the listing; the continent and
|
||||
region; whether it is a remote opportunity; the date by which a job needs to be filled; and a full description of
|
||||
the position, using #[a(href="#markdown") Markdown]. Once you save the listing, it will be visible to the other
|
||||
citizens here.
|
||||
|
||||
h5 Maintain and Share Your Job Listings
|
||||
p.
|
||||
The #[span.link My Job Listings] page will show you all of your active job listings just below the
|
||||
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
|
||||
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
|
||||
the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
|
||||
view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s
|
||||
access, but then they will get there as well.)
|
||||
|
||||
h5 Expire a Job Listing
|
||||
p.
|
||||
Once the job is filled, or the opportunity has passed, you will want to expire the listing; this is what the
|
||||
#[span.link Expire] link allows you to do. When you click it, you will be presented with a single question –
|
||||
was the job filled due to its listing here? If not, leave that blank, click the #[span.button Expire] button, and
|
||||
the listing will be expired. If you click that box, though, another Markdown editor will appear, where you can share
|
||||
a story of the experience. This is not required, but if you put text there, it will be recorded as a Success Story,
|
||||
and other users will be able to read about your success.
|
||||
p.
|
||||
Once you have at least one expired job listing, the #[span.link My Job Listing] page will have a new section below
|
||||
your active listings, where you can see your expired ones. You can still view the expired listing, and links that
|
||||
you may have shared will still pull up the listing; there will be an “expired” label beside the title,
|
||||
so that whoever is viewing it knows that they are reading about a job that is no longer available.
|
||||
|
||||
hr
|
||||
|
||||
h4#profile-search Searching Profiles
|
||||
p.
|
||||
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
|
||||
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you
|
||||
find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links
|
||||
to their Mastodon profile, where you can use its features to get in touch.
|
||||
|
||||
hr
|
||||
|
||||
h4#profile Your Employment Profile
|
||||
p.
|
||||
The employment profile is your résumé, visible to other citizens here. It also allows you to specify
|
||||
your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
|
||||
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
|
||||
current display name each time you log on.
|
||||
|
||||
h5 Completing Your Profile
|
||||
p.
|
||||
The #[span.link My Employment Profile] page lets you establish or modify your employment profile; the
|
||||
#[span.link Dashboard] page also has buttons that let you create, edit, and view your profile.
|
||||
ul
|
||||
li.
|
||||
The #[span.link Professional Biography] is the “Objective” part of a traditional résumé.
|
||||
This section supports #[a(href="#markdown") Markdown], so you can include actual headings, formatting, etc.
|
||||
li.
|
||||
Skills are optional, but they are the place to record skills you have. Along with each skill, there is a
|
||||
#[span.link Notes] field, which can be used to indicate the time you’ve practiced a particular skill, the
|
||||
mastery you have of that skill, etc. It is free-form text, so it is all up to you how you utilize the field.
|
||||
li.
|
||||
The #[span.link Experience] field is intended to capture a chronological or topical employment history. This
|
||||
Markdown space can be used to capture chronological history, certifications, or any other information –
|
||||
however you would like it presented to fellow citizens.
|
||||
#[em.text-muted (If you would like a chronological job builder, reach out and let us know.)]
|
||||
li.
|
||||
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
|
||||
employment, your continent, region, and skills fields will be searchable and displayed to public users of the
|
||||
site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
|
||||
curtain a bit, and hopefully inspire them to join us.
|
||||
|
||||
h5 Viewing and Sharing Your Profile
|
||||
p.
|
||||
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
|
||||
that will let you view your profile the way all other validated users will be able to see it. (There will also be a
|
||||
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
|
||||
Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
|
||||
there, while others will get there once they authorize this application.
|
||||
p.
|
||||
The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there,
|
||||
others can communicate further with you using the tools Mastodon provides.
|
||||
|
||||
h5 “I Found a Job!”
|
||||
p.
|
||||
If your profile indicates that you are seeking employment, and you secure employment, that is something you will
|
||||
want to update (and – congratulations!). From both the #[span.link Dashboard] and
|
||||
#[span.link My Employment Profile] pages, you will see a link that encourages you to tell us about it. Click either
|
||||
of those links, and you will be brought to a page that allows you to indicate whether your employment actually came
|
||||
from someone finding your profile on Jobs, Jobs, Jobs, and gives you a place to write about the experience. These
|
||||
stories are only viewable by validated users, so feel free to use as much (or as little) identifying information as
|
||||
you’d like. You can also submit this page with all the fields blank; in that case, your “Seeking
|
||||
Employment” flag is cleared, and the blank story is recorded.
|
||||
p.
|
||||
As a validated user, you can also view others success stories. Clicking #[span.link Success Stories] in the sidebar
|
||||
will display a list of all the stories that have been recorded. If there is a story to be read, there will be a link
|
||||
to read it; if you submitted the story, there will also be an #[span.link Edit] link.
|
||||
|
||||
h5 Publicly Available Information
|
||||
p.
|
||||
The #[span.link Job Seekers] page for profile information will allow users to search for and display the continent,
|
||||
region, skills, and notes of users who are seeking employment #[strong and] have opted in to their information being
|
||||
publicly searchable. If you are a public user, this information is always the latest we have; check out the link at
|
||||
the top of the search results for how you can learn more about these fine human resources!
|
||||
|
||||
hr
|
||||
|
||||
h4#markdown A Bit about Markdown
|
||||
p.
|
||||
Markdown is a plain-text way to specify formatting quite similar to that provided by word processors. The
|
||||
#[a(href="https://daringfireball.net/projects/markdown/" target="_blank") original page] for the project is a good
|
||||
good overview of its capabilities, and the pages at
|
||||
#[a(href="https://www.markdownguide.org/" target="_blank") Markdown Guide] give in-depth lessons to make the most of
|
||||
this language. The version of Markdown employed here supports many popular extensions, include smart quotes (turning
|
||||
"a quote" into “a quote”), tables, super/subscripts, and more.
|
||||
|
||||
hr
|
||||
|
||||
h4 Help / Suggestions
|
||||
p.
|
||||
This is open-source software
|
||||
#[a(href="https://github.com/bit-badger/jobs-jobs-jobs" _target="_blank") developed on Github]; feel free to
|
||||
#[a(href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank") create an issue there], or look up
|
||||
@danieljsummers on No Agenda Social.
|
||||
</template>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
span.link
|
||||
background-color: rgba(144, 238, 144, .25)
|
||||
span.button
|
||||
border: solid 1px lightgreen
|
||||
border-radius: .25rem
|
||||
span.link,
|
||||
span.button
|
||||
padding: 0 .25rem
|
||||
</style>
|
||||
@@ -1,348 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3 Privacy Policy
|
||||
p: em (as of September 6#[sup th], 2021)
|
||||
|
||||
p.
|
||||
{{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This
|
||||
Privacy Policy explains how your personal information is collected, used, and disclosed by {{name}}.
|
||||
p.
|
||||
This Privacy Policy applies to our website, and its associated subdomains (collectively, our “Service”)
|
||||
alongside our application, {{name}}. By accessing or using our Service, you signify that you have read, understood,
|
||||
and agree to our collection, storage, use, and disclosure of your personal information as described in this Privacy
|
||||
Policy and our Terms of Service.
|
||||
|
||||
h4 Definitions and key terms
|
||||
p.
|
||||
To help explain things as clearly as possible in this Privacy Policy, every time any of these terms are referenced,
|
||||
are strictly defined as:
|
||||
ul
|
||||
li.
|
||||
Cookie: small amount of data generated by a website and saved by your web browser. It is used to identify your
|
||||
browser, provide analytics, remember information about you such as your language preference or login information.
|
||||
li.
|
||||
Company: when this policy mentions “Company,” “we,” “us,” or
|
||||
“our,” it refers to {{name}}, that is responsible for your information under this Privacy Policy.
|
||||
li Country: where {{name}} or the owners/founders of {{name}} are based, in this case is US.
|
||||
li.
|
||||
Customer: refers to the company, organization or person that signs up to use the {{name}} Service to manage the
|
||||
relationships with your consumers or service users.
|
||||
li.
|
||||
Device: any internet connected device such as a phone, tablet, computer or any other device that can be used to
|
||||
visit {{name}} and use the services.
|
||||
li.
|
||||
IP address: Every device connected to the Internet is assigned a number known as an Internet protocol (IP)
|
||||
address. These numbers are usually assigned in geographic blocks. An IP address can often be used to identify the
|
||||
location from which a device is connecting to the Internet.
|
||||
li.
|
||||
Personnel: refers to those individuals who are employed by {{name}} or are under contract to perform a service on
|
||||
behalf of one of the parties.
|
||||
li.
|
||||
Personal Data: any information that directly, indirectly, or in connection with other information — including a
|
||||
personal identification number — allows for the identification or identifiability of a natural person.
|
||||
li.
|
||||
Service: refers to the service provided by {{name}} as described in the relative terms (if available) and on this
|
||||
platform.
|
||||
li.
|
||||
Third-party service: refers to advertisers, contest sponsors, promotional and marketing partners, and others who
|
||||
provide our content or whose products or services we think may interest you.
|
||||
li.
|
||||
Website: {{name}}’s site, which can be accessed via this URL:
|
||||
#[router-link(to="/") https://noagendacareers.com/]
|
||||
li You: a person or entity that is registered with {{name}} to use the Services.
|
||||
|
||||
h4 What Information Do We Collect?
|
||||
p We collect information from you when you visit our website, register on our site, or fill out a form.
|
||||
ul
|
||||
li Name / Username
|
||||
li Coarse Geographic Location
|
||||
li Employment History
|
||||
li Mastodon Account Name / Profile
|
||||
|
||||
h4 How Do We Use The Information We Collect?
|
||||
p Any of the information we collect from you may be used in one of the following ways:
|
||||
ul
|
||||
li To personalize your experience (your information helps us to better respond to your individual needs)
|
||||
li.
|
||||
To improve our website (we continually strive to improve our website offerings based on the information and
|
||||
feedback we receive from you)
|
||||
li.
|
||||
To improve customer service (your information helps us to more effectively respond to your customer service
|
||||
requests and support needs)
|
||||
|
||||
h4 When does {{name}} use end user information from third parties?
|
||||
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
|
||||
p.
|
||||
End users may voluntarily provide us with information they have made available on social media websites
|
||||
(specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
|
||||
publicly available information from the social media websites you have indicated. You can control how much of your
|
||||
information social media websites make public by visiting these websites and changing your privacy settings.
|
||||
|
||||
h4 When does {{name}} use customer information from third parties?
|
||||
p We do not utilize third party information apart from the end-user data described above.
|
||||
|
||||
h4 Do we share the information we collect with third parties?
|
||||
p.
|
||||
We may disclose personal and non-personal information about you to government or law enforcement officials or
|
||||
private parties as we, in our sole discretion, believe necessary or appropriate in order to respond to claims, legal
|
||||
process (including subpoenas), to protect our rights and interests or those of a third party, the safety of the
|
||||
public or any person, to prevent or stop any illegal, unethical, or legally actionable activity, or to otherwise
|
||||
comply with applicable court orders, laws, rules and regulations.
|
||||
|
||||
h4 Where and when is information collected from customers and end users?
|
||||
p.
|
||||
{{name}} will collect personal information that you submit to us. We may also receive personal information about you
|
||||
from third parties as described above.
|
||||
|
||||
h4 How Do We Use Your E-mail Address?
|
||||
p.
|
||||
We do not collect nor use an e-mail address. If you have provided it in the free text areas of the site, other
|
||||
validated users may be able to view it, but {{name}} does not search for nor utilize e-mail addresses from those
|
||||
areas.
|
||||
|
||||
h4 How Long Do We Keep Your Information?
|
||||
p.
|
||||
We keep your information only so long as we need it to provide {{name}} to you and fulfill the purposes described in
|
||||
this policy. When we no longer need to use your information and there is no need for us to keep it to comply with
|
||||
our legal or regulatory obligations, we’ll either remove it from our systems or depersonalize it so that we
|
||||
can’t identify you.
|
||||
|
||||
h4 How Do We Protect Your Information?
|
||||
p.
|
||||
We implement a variety of security measures to maintain the safety of your personal information when you enter,
|
||||
submit, or access your personal information. We mandate the use of a secure server. We cannot, however, ensure or
|
||||
warrant the absolute security of any information you transmit to {{name}} or guarantee that your information on the
|
||||
Service may not be accessed, disclosed, altered, or destroyed by a breach of any of our physical, technical, or
|
||||
managerial safeguards.
|
||||
|
||||
h4 Could my information be transferred to other countries?
|
||||
p.
|
||||
{{name}} is hosted in the US. Information collected via our website may be viewed and hosted anywhere in the world,
|
||||
including countries that may not have laws of general applicability regulating the use and transfer of such data. To
|
||||
the fullest extent allowed by applicable law, by using any of the above, you voluntarily consent to the trans-border
|
||||
transfer and hosting of such information.
|
||||
|
||||
h4 Is the information collected through the {{name}} Service secure?
|
||||
p.
|
||||
We take precautions to protect the security of your information. We have physical, electronic, and managerial
|
||||
procedures to help safeguard, prevent unauthorized access, maintain data security, and correctly use your
|
||||
information. However, neither people nor security systems are foolproof, including encryption systems. In addition,
|
||||
people can commit intentional crimes, make mistakes, or fail to follow policies. Therefore, while we use reasonable
|
||||
efforts to protect your personal information, we cannot guarantee its absolute security. If applicable law imposes
|
||||
any non-disclaimable duty to protect your personal information, you agree that intentional misconduct will be the
|
||||
standards used to measure our compliance with that duty.
|
||||
|
||||
h4 Can I update or correct my information?
|
||||
p.
|
||||
The rights you have to request updates or corrections to the information {{name}} collects depend on your
|
||||
relationship with {{name}}.
|
||||
p.
|
||||
Customers have the right to request the restriction of certain uses and disclosures of personally identifiable
|
||||
information as follows. You can contact us in order to (1) update or correct your personally identifiable
|
||||
information, or (3) delete the personally identifiable information maintained about you on our systems (subject to
|
||||
the following paragraph), by cancelling your account. Such updates, corrections, changes and deletions will have no
|
||||
effect on other information that we maintain in accordance with this Privacy Policy prior to such update,
|
||||
correction, change, or deletion. You are responsible for maintaining the secrecy of your unique password and account
|
||||
information at all times.
|
||||
p.
|
||||
{{name}} also provides ways for users to modify or remove the information we have collected from them from the
|
||||
application; these actions will have the same effect as contacting us to modify or remove data.
|
||||
p.
|
||||
You should be aware that it is not technologically possible to remove each and every record of the information you
|
||||
have provided to us from our system. The need to back up our systems to protect information from inadvertent loss
|
||||
means that a copy of your information may exist in a non-erasable form that will be difficult or impossible for us
|
||||
to locate. Promptly after receiving your request, all personal information stored in databases we actively use, and
|
||||
other readily searchable media will be updated, corrected, changed, or deleted, as appropriate, as soon as and to
|
||||
the extent reasonably and technically practicable.
|
||||
p.
|
||||
If you are an end user and wish to update, delete, or receive any information we have about you, you may do so by
|
||||
contacting the organization of which you are a customer.
|
||||
|
||||
h4 Governing Law
|
||||
p.
|
||||
This Privacy Policy is governed by the laws of US without regard to its conflict of laws provision. You consent to
|
||||
the exclusive jurisdiction of the courts in connection with any action or dispute arising between the parties under
|
||||
or in connection with this Privacy Policy except for those individuals who may have rights to make claims under
|
||||
Privacy Shield, or the Swiss-US framework.
|
||||
p.
|
||||
The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of the website. Your
|
||||
use of the website may also be subject to other local, state, national, or international laws.
|
||||
p.
|
||||
By using {{name}} or contacting us directly, you signify your acceptance of this Privacy Policy. If you do not agree
|
||||
to this Privacy Policy, you should not engage with our website, or use our services. Continued use of the website,
|
||||
direct engagement with us, or following the posting of changes to this Privacy Policy that do not significantly
|
||||
affect the use or disclosure of your personal information will mean that you accept those changes.
|
||||
|
||||
h4 Your Consent
|
||||
p.
|
||||
We’ve updated our Privacy Policy to provide you with complete transparency into what is being set when you
|
||||
visit our site and how it’s being used. By using our website, registering an account, or making a purchase,
|
||||
you hereby consent to our Privacy Policy and agree to its terms.
|
||||
|
||||
h4 Links to Other Websites
|
||||
p.
|
||||
This Privacy Policy applies only to the Services. The Services may contain links to other websites not operated or
|
||||
controlled by {{name}}. We are not responsible for the content, accuracy or opinions expressed in such websites, and
|
||||
such websites are not investigated, monitored or checked for accuracy or completeness by us. Please remember that
|
||||
when you use a link to go from the Services to another website, our Privacy Policy is no longer in effect. Your
|
||||
browsing and interaction on any other website, including those that have a link on our platform, is subject to that
|
||||
website’s own rules and policies. Such third parties may use their own cookies or other methods to collect
|
||||
information about you.
|
||||
|
||||
h4 Cookies
|
||||
p {{name}} does not use Cookies.
|
||||
|
||||
h4 Kids’ Privacy
|
||||
p.
|
||||
We do not address anyone under the age of 13. We do not knowingly collect personally identifiable information from
|
||||
anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with
|
||||
Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age
|
||||
of 13 without verification of parental consent, We take steps to remove that information from Our servers.
|
||||
|
||||
h4 Changes To Our Privacy Policy
|
||||
p.
|
||||
We may change our Service and policies, and we may need to make changes to this Privacy Policy so that they
|
||||
accurately reflect our Service and policies. Unless otherwise required by law, we will notify you (for example,
|
||||
through our Service) before we make changes to this Privacy Policy and give you an opportunity to review them before
|
||||
they go into effect. Then, if you continue to use the Service, you will be bound by the updated Privacy Policy. If
|
||||
you do not want to agree to this or any updated Privacy Policy, you can delete your account.
|
||||
|
||||
h4 Third-Party Services
|
||||
p.
|
||||
We may display, include or make available third-party content (including data, information, applications and other
|
||||
products services) or provide links to third-party websites or services (“Third-Party Services”).
|
||||
p.
|
||||
You acknowledge and agree that {{name}} shall not be responsible for any Third-Party Services, including their
|
||||
accuracy, completeness, timeliness, validity, copyright compliance, legality, decency, quality or any other aspect
|
||||
thereof. {{name}} does not assume and shall not have any liability or responsibility to you or any other person or
|
||||
entity for any Third-Party Services.
|
||||
p.
|
||||
Third-Party Services and links thereto are provided solely as a convenience to you and you access and use them
|
||||
entirely at your own risk and subject to such third parties’ terms and conditions.
|
||||
|
||||
h4 Tracking Technologies
|
||||
p.
|
||||
{{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
|
||||
stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is refreshed
|
||||
or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
|
||||
can be used again.
|
||||
|
||||
h4 Information about General Data Protection Regulation (GDPR)
|
||||
p.
|
||||
We may be collecting and using information from you if you are from the European Economic Area (EEA), and in this
|
||||
section of our Privacy Policy we are going to explain exactly how and why is this data collected, and how we
|
||||
maintain this data under protection from being replicated or used in the wrong way.
|
||||
|
||||
h5 What is GDPR?
|
||||
p.
|
||||
GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is protected by
|
||||
companies and enhances the control the EU residents have, over their personal data.
|
||||
p.
|
||||
The GDPR is relevant to any globally operating company and not just the EU-based businesses and EU residents. Our
|
||||
customers’ data is important irrespective of where they are located, which is why we have implemented GDPR controls
|
||||
as our baseline standard for all our operations worldwide.
|
||||
|
||||
h5 What is personal data?
|
||||
p.
|
||||
Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum of information that
|
||||
could be used on its own, or in combination with other pieces of information, to identify a person. Personal data
|
||||
extends beyond a person’s name or email address. Some examples include financial information, political opinions,
|
||||
genetic data, biometric data, IP addresses, physical address, sexual orientation, and ethnicity.
|
||||
p The Data Protection Principles include requirements such as:
|
||||
ul
|
||||
li.
|
||||
Personal data collected must be processed in a fair, legal, and transparent way and should only be used in a way
|
||||
that a person would reasonably expect.
|
||||
li.
|
||||
Personal data should only be collected to fulfil a specific purpose and it should only be used for that purpose.
|
||||
Organizations must specify why they need the personal data when they collect it.
|
||||
li Personal data should be held no longer than necessary to fulfil its purpose.
|
||||
li.
|
||||
People covered by the GDPR have the right to access their own personal data. They can also request a copy of their
|
||||
data, and that their data be updated, deleted, restricted, or moved to another organization.
|
||||
|
||||
h5 Why is GDPR important?
|
||||
p.
|
||||
GDPR adds some new requirements regarding how companies should protect individuals’ personal data that they
|
||||
collect and process. It also raises the stakes for compliance by increasing enforcement and imposing greater fines
|
||||
for breach. Beyond these facts, it’s simply the right thing to do. At {{name}} we strongly believe that your
|
||||
data privacy is very important and we already have solid security and privacy practices in place that go beyond the
|
||||
requirements of this regulation.
|
||||
|
||||
h5 Individual Data Subject’s Rights - Data Access, Portability, and Deletion
|
||||
p.
|
||||
We are committed to helping our customers meet the data subject rights requirements of GDPR. {{name}} processes or
|
||||
stores all personal data in fully vetted, DPA compliant vendors. We do store all conversation and personal data for
|
||||
up to 6 years unless your account is deleted. In which case, we dispose of all data in accordance with our Terms of
|
||||
Service and Privacy Policy, but we will not hold it longer than 60 days.
|
||||
p.
|
||||
We are aware that if you are working with EU customers, you need to be able to provide them with the ability to
|
||||
access, update, retrieve and remove personal data. We got you! We’ve been set up as self service from the
|
||||
start and have always given you access to your data. Our customer support team is here for you to answer any
|
||||
questions you might have about working with the API.
|
||||
|
||||
h4 California Residents
|
||||
p.
|
||||
The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal Information we collect and
|
||||
how we use it, the categories of sources from whom we collect Personal Information, and the third parties with whom
|
||||
we share it, which we have explained above.
|
||||
p.
|
||||
We are also required to communicate information about rights California residents have under California law. You may
|
||||
exercise the following rights:
|
||||
ul
|
||||
li.
|
||||
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
||||
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
||||
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
||||
pieces of Personal Information we have collected about you.
|
||||
li Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.
|
||||
li.
|
||||
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
||||
about you that we have collected.
|
||||
li Request that a business that sells a consumer’s personal data, not sell the consumer’s personal data.
|
||||
p.
|
||||
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights,
|
||||
please contact us.
|
||||
p We do not sell the Personal Information of our users.
|
||||
p For more information about these rights, please contact us.
|
||||
|
||||
h5 California Online Privacy Protection Act (CalOPPA)
|
||||
p.
|
||||
CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, the categories of
|
||||
sources from whom we collect Personal Information, and the third parties with whom we share it, which we have
|
||||
explained above.
|
||||
p CalOPPA users have the following rights:
|
||||
ul
|
||||
li.
|
||||
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
||||
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
||||
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
||||
pieces of Personal Information we have collected about you.
|
||||
li Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.
|
||||
li.
|
||||
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
||||
about you that we have collected.
|
||||
li.
|
||||
Right to request that a business that sells a consumer’s personal data, not sell the consumer’s
|
||||
personal data.
|
||||
p.
|
||||
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights,
|
||||
please contact us.
|
||||
p We do not sell the Personal Information of our users.
|
||||
p For more information about these rights, please contact us.
|
||||
|
||||
h4 Contact Us
|
||||
p Don’t hesitate to contact us if you have any questions.
|
||||
ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works]
|
||||
|
||||
hr
|
||||
|
||||
p: em.
|
||||
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with generic terms for any
|
||||
authorized Mastodon instance.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** The name of the application */
|
||||
const name = "Jobs, Jobs, Jobs"
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3 Terms of Service
|
||||
p: em (as of September 6#[sup th], 2021)
|
||||
|
||||
h4 Acceptance of Terms
|
||||
p.
|
||||
By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible
|
||||
to ensure that your use of this site complies with all applicable laws. Your continued use of this site implies your
|
||||
acceptance of these terms.
|
||||
|
||||
h4 Description of Service and Registration
|
||||
p
|
||||
| Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
||||
| to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
|
||||
= " "
|
||||
template(v-for="(it, idx) in instances" :key="idx")
|
||||
a(:href="it.url" target="_blank") {{it.name}}
|
||||
template(v-if="idx + 2 < instances.length")= ", "
|
||||
template(v-else-if="idx + 1 < instances.length")= ", and "
|
||||
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one’s Mastodon profile. See our
|
||||
= " "
|
||||
router-link(to="/privacy-policy") privacy policy
|
||||
= " "
|
||||
| for details on the personal (user) information we maintain.
|
||||
|
||||
h4 Liability
|
||||
p.
|
||||
This service is provided “as is”, and no warranty (express or implied) exists. The service and its
|
||||
developers may not be held liable for any damages that may arise through the use of this service.
|
||||
|
||||
h4 Updates to Terms
|
||||
p.
|
||||
These terms and conditions may be updated at any time. When these terms are updated, users will be notified via a
|
||||
notice on the dashboard page. Additionally, the date at the top of this page will be updated, and any substantive
|
||||
updates will also be accompanied by a summary of those changes.
|
||||
|
||||
hr
|
||||
|
||||
p.
|
||||
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
|
||||
data.
|
||||
|
||||
hr
|
||||
|
||||
p: em.
|
||||
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with a list of all No
|
||||
Agenda-affiliated Mastodon instances.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** All instances authorized to view Jobs, Jobs, Jobs */
|
||||
const instances = computed(() => store.state.instances)
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
p
|
||||
p(v-html="message")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useStore, Actions, Mutations } from "@/store"
|
||||
import { AFTER_LOG_ON_URL } from "@/router"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** The abbreviation of the instance from which we received the code */
|
||||
const abbr = route.params.abbr as string
|
||||
|
||||
/** Set the message for this component */
|
||||
const setMessage = (msg : string) => store.commit(Mutations.SetLogOnState, msg)
|
||||
|
||||
/** Pass the code to the API and exchange it for a user and a JWT */
|
||||
const logOn = async () => {
|
||||
await store.dispatch(Actions.EnsureInstances)
|
||||
const instance = store.state.instances.find(it => it.abbr === abbr)
|
||||
if (typeof instance === "undefined") {
|
||||
setMessage(`Mastodon instance ${abbr} not found`)
|
||||
} else {
|
||||
setMessage(`<em>Welcome back! Verifying your ${instance.name} account…</em>`)
|
||||
const code = route.query.code
|
||||
if (code) {
|
||||
await store.dispatch(Actions.LogOn, { abbr, code })
|
||||
if (store.state.user !== undefined) {
|
||||
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||
if (afterLogOnUrl) {
|
||||
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||
router.push(afterLogOnUrl)
|
||||
} else {
|
||||
router.push("/citizen/dashboard")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked “Cancel”?)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(logOn)
|
||||
|
||||
/** Accessor for the log on state */
|
||||
const message = computed(() => store.state.logOnState)
|
||||
</script>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template lang="pug">
|
||||
article.container
|
||||
h3.pb-4 Welcome, {{user.name}}
|
||||
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
|
||||
.col: .card.h-100
|
||||
h5.card-header Your Profile
|
||||
.card-body
|
||||
h6.card-subtitle.mb-3.text-muted.fst-italic(v-if="profile").
|
||||
Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
||||
p.card-text(v-if="profile")
|
||||
| Your profile currently lists {{profile.skills.length}}
|
||||
| skill#[template(v-if="profile.skills.length !== 1") s].
|
||||
span(v-if="profile.seekingEmployment")
|
||||
br
|
||||
br
|
||||
| Your profile indicates that you are seeking employment. Once you find it,
|
||||
router-link(to="/success-story/add") tell your fellow citizens about it!
|
||||
p.card-text(v-else).
|
||||
You do not have an employment profile established; click below (or “Edit Profile” in the menu) to
|
||||
get started!
|
||||
.card-footer
|
||||
template(v-if="profile")
|
||||
router-link.btn.btn-outline-secondary(:to="`/profile/${user.citizenId}/view`") View Profile
|
||||
|
|
||||
router-link.btn.btn-outline-secondary(to="/citizen/profile") Edit Profile
|
||||
router-link.btn.btn-primary(v-else to="/citizen/profile") Create Profile
|
||||
.col: .card.h-100
|
||||
h5.card-header Other Citizens
|
||||
.card-body
|
||||
h6.card-subtitle.mb-3.text-muted.fst-italic
|
||||
template(v-if="profileCount === 0") No
|
||||
template(v-else) {{profileCount}} Total
|
||||
| Employment Profile#[template(v-if="profileCount !== 1") s]
|
||||
p.card-text(v-if="profileCount === 1 && profile") It looks like, for now, it’s just you…
|
||||
p.card-text(v-else-if="profileCount > 0") Take a look around and see if you can help them find work!
|
||||
p.card-text(v-else) You can click below, but you will not find anything…
|
||||
.card-footer: router-link.btn.btn-outline-secondary(to="/profile/search") Search Profiles
|
||||
p
|
||||
p.
|
||||
To see how this application works, check out “How It Works” in the sidebar (last updated August
|
||||
29#[sup th], 2021).
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from "vue"
|
||||
import api, { LogOnSuccess, Profile } from "@/api"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import FullDateTime from "@/components/FullDateTime.vue"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The currently logged-in user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** The user's profile */
|
||||
const profile : Ref<Profile | undefined> = ref(undefined)
|
||||
|
||||
/** A count of profiles in the system */
|
||||
const profileCount = ref(0)
|
||||
|
||||
const retrieveData = async (errors : string[]) => {
|
||||
const profileResult = await api.profile.retreive(undefined, user)
|
||||
if (typeof profileResult === "string") {
|
||||
errors.push(profileResult)
|
||||
} else if (typeof profileResult !== "undefined") {
|
||||
profile.value = profileResult
|
||||
}
|
||||
const count = await api.profile.count(user)
|
||||
if (typeof count === "string") {
|
||||
errors.push(count)
|
||||
} else {
|
||||
profileCount.value = count
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,184 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 My Employment Profile
|
||||
load-data(:load="retrieveData"): form.row.g-3
|
||||
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||
.form-floating
|
||||
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
|
||||
placeholder="Leave blank to use your Mastodon display name")
|
||||
label(for="realName") Real Name
|
||||
.form-text Leave blank to use your Mastodon display name
|
||||
.col-12
|
||||
.form-check
|
||||
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")
|
||||
label.form-check-label(for="isSeeking") I am currently seeking employment
|
||||
p(v-if="profile.isSeekingEmployment"): em.
|
||||
If you have found employment, consider
|
||||
#[router-link(to="/success-story/new/edit") telling your fellow citizens about it!]
|
||||
.col-12.col-sm-6.col-md-4
|
||||
continent-list(v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||
@touch="v$.continentId.$touch() || true")
|
||||
.col-12.col-sm-6.col-md-8
|
||||
.form-floating
|
||||
input.form-control(type="text" id="region" :class="{ 'is-invalid': v$.region.$error }"
|
||||
v-model="v$.region.$model" maxlength="255"
|
||||
placeholder="Country, state, geographic area, etc.")
|
||||
#regionFeedback.invalid-feedback Please enter a region
|
||||
label.jjj-required(for="region") Region
|
||||
.form-text Country, state, geographic area, etc.
|
||||
markdown-editor(id="bio" label="Professional Biography" v-model:text="v$.biography.$model"
|
||||
:isInvalid="v$.biography.$error")
|
||||
.col-12.col-offset-md-2.col-md-4
|
||||
.form-check
|
||||
input.form-check-input(type="checkbox" id="isRemote" v-model="v$.remoteWork.$model")
|
||||
label.form-check-label(for="isRemote") I am looking for remote work
|
||||
.col-12.col-md-4
|
||||
.form-check
|
||||
input.form-check-input(type="checkbox" id="isFullTime" v-model="v$.fullTime.$model")
|
||||
label.form-check-label(for="isFullTime") I am looking for full-time work
|
||||
.col-12
|
||||
hr
|
||||
h4.pb-2 Skills #[button.btn.btn-sm.btn-outline-primary.rounded-pill(@click.prevent="addSkill") Add a Skill]
|
||||
profile-skill-edit(v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
||||
@remove="removeSkill(skill.id)" @input="v$.skills.$touch")
|
||||
.col-12
|
||||
hr
|
||||
h4 Experience
|
||||
p.
|
||||
This application does not have a place to individually list your chronological job history; however, you can use
|
||||
this area to list prior jobs, their dates, and anything else you want to include that’s not already a part
|
||||
of your Professional Biography above.
|
||||
markdown-editor(id="experience" label="Experience" v-model:text="v$.experience.$model")
|
||||
.col-12: .form-check
|
||||
input.form-check-input(type="checkbox" id="isPublic" v-model="v$.isPublic.$model")
|
||||
label.form-check-label(for="isPublic") Allow my profile to be searched publicly (outside NA Social)
|
||||
.col-12
|
||||
p.text-danger(v-if="v$.$error") Please correct the errors above
|
||||
button.btn.btn-primary(@click.prevent="saveProfile") #[icon(:icon="mdiContentSaveOutline")] Save
|
||||
template(v-if="!isNew")
|
||||
|
|
||||
router-link.btn.btn-outline-secondary(:to="`/profile/${user.citizenId}/view`").
|
||||
#[icon(color="#6c757d" :icon="mdiFileAccountOutline")] View Your User Profile
|
||||
hr
|
||||
p.text-muted.fst-italic.
|
||||
(If you want to delete your profile, or your entire account,
|
||||
#[router-link(to="/so-long/options") see your deletion options here].)
|
||||
maybe-save(:saveAction="saveProfile" :validator="v$")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, reactive } from "vue"
|
||||
import { mdiContentSaveOutline, mdiFileAccountOutline } from "@mdi/js"
|
||||
import useVuelidate from "@vuelidate/core"
|
||||
import { required } from "@vuelidate/validators"
|
||||
|
||||
import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { 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"
|
||||
import ProfileSkillEdit from "@/components/profile/SkillEdit.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** Whether this is a new profile */
|
||||
const isNew = ref(false)
|
||||
|
||||
/** The starting values for a new employment profile */
|
||||
const newProfile : Profile = {
|
||||
id: user.citizenId,
|
||||
seekingEmployment: false,
|
||||
isPublic: false,
|
||||
continentId: "",
|
||||
region: "",
|
||||
remoteWork: false,
|
||||
fullTime: false,
|
||||
biography: "",
|
||||
lastUpdatedOn: "",
|
||||
experience: undefined,
|
||||
skills: []
|
||||
}
|
||||
|
||||
/** The user's current profile (plus a few items, adapted for editing) */
|
||||
const profile = reactive(new ProfileForm())
|
||||
|
||||
/** The validation rules for the form */
|
||||
const rules = computed(() => ({
|
||||
realName: { },
|
||||
isSeekingEmployment: { },
|
||||
isPublic: { },
|
||||
continentId: { required },
|
||||
region: { required },
|
||||
remoteWork: { },
|
||||
fullTime: { },
|
||||
biography: { required },
|
||||
experience: { },
|
||||
skills: { }
|
||||
}))
|
||||
|
||||
/** Initialize form validation */
|
||||
const v$ = useVuelidate(rules, profile, { $lazy: true })
|
||||
|
||||
/** Retrieve the user's profile and their real name */
|
||||
const retrieveData = async (errors : string[]) => {
|
||||
const profileResult = await api.profile.retreive(undefined, user)
|
||||
if (typeof profileResult === "string") {
|
||||
errors.push(profileResult)
|
||||
} else if (typeof profileResult === "undefined") {
|
||||
isNew.value = true
|
||||
}
|
||||
const nameResult = await api.citizen.retrieve(user.citizenId, user)
|
||||
if (typeof nameResult === "string") {
|
||||
errors.push(nameResult)
|
||||
}
|
||||
if (errors.length > 0) return
|
||||
// Update the empty form with appropriate values
|
||||
const p = isNew.value ? newProfile : profileResult as Profile
|
||||
profile.isSeekingEmployment = p.seekingEmployment
|
||||
profile.isPublic = p.isPublic
|
||||
profile.continentId = p.continentId
|
||||
profile.region = p.region
|
||||
profile.remoteWork = p.remoteWork
|
||||
profile.fullTime = p.fullTime
|
||||
profile.biography = p.biography
|
||||
profile.experience = p.experience
|
||||
profile.skills = p.skills
|
||||
profile.realName = typeof nameResult !== "undefined" ? (nameResult as Citizen).realName ?? "" : ""
|
||||
}
|
||||
|
||||
/** The ID for new skills */
|
||||
let newSkillId = 0
|
||||
|
||||
/** Add a skill to the profile */
|
||||
const addSkill = () => {
|
||||
profile.skills.push({ id: `new${newSkillId++}`, description: "", notes: undefined })
|
||||
v$.value.skills.$touch()
|
||||
}
|
||||
|
||||
/** Remove the given skill from the profile */
|
||||
const removeSkill = (skillId : string) => {
|
||||
profile.skills = profile.skills.filter(s => s.id !== skillId)
|
||||
v$.value.skills.$touch()
|
||||
}
|
||||
|
||||
/** Save the current profile values */
|
||||
const saveProfile = async () => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
// Remove any blank skills before submitting
|
||||
profile.skills = profile.skills.filter(s => !(s.description.trim() === "" && (s.notes ?? "").trim() === ""))
|
||||
const saveResult = await api.profile.save(profile, user)
|
||||
if (typeof saveResult === "string") {
|
||||
toastError(saveResult, "saving profile")
|
||||
} else {
|
||||
toastSuccess("Profile Saved Successfuly")
|
||||
v$.value.$reset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
p
|
||||
p.fst-italic Logging off…
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore, Mutations } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
store.commit(Mutations.ClearUser)
|
||||
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
||||
router.push("/")
|
||||
})
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
p
|
||||
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second…
|
||||
template(v-else)
|
||||
p.text-center Please select your No Agenda-affiliated Mastodon instance
|
||||
p.text-center(v-for="it in instances" :key="it.abbr")
|
||||
template(v-if="it.isEnabled")
|
||||
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
|
||||
template(v-else).
|
||||
#[button.btn.btn-secondary(disabled="disabled") {{it.name}}]#[br]#[em {{it.reason}}]
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, Ref, ref } from "vue"
|
||||
import { Instance } from "@/api"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The instances configured for Jobs, Jobs, Jobs */
|
||||
const instances = computed(() => store.state.instances)
|
||||
|
||||
/** Whether authorization is in progress */
|
||||
const selected : Ref<Instance | undefined> = ref(undefined)
|
||||
|
||||
/** The authorization URL to which the user should be directed */
|
||||
const authUrl = computed(() => {
|
||||
if (selected.value) {
|
||||
const client = `client_id=${selected.value.clientId}`
|
||||
const scope = "scope=read:accounts"
|
||||
const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
|
||||
const respType = "response_type=code"
|
||||
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
/**
|
||||
* Select a given Mastodon instance
|
||||
*
|
||||
* @param abbr The abbreviation of the instance being selected
|
||||
*/
|
||||
const select = (abbr : string) => {
|
||||
selected.value = instances.value.find(it => it.abbr === abbr)
|
||||
document.location.assign(authUrl.value)
|
||||
}
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
@@ -1,116 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 Help Wanted
|
||||
p(v-if="!searched").
|
||||
Enter relevant criteria to find results, or just click “Search” to see all current job listings.
|
||||
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||
listing-search-form(v-model="criteria" @search="doSearch")
|
||||
error-list(:errors="errors")
|
||||
p.pt-3(v-if="searching") Searching job listings…
|
||||
template(v-else)
|
||||
table.table.table-sm.table-hover.pt-3(v-if="results.length > 0")
|
||||
thead: tr
|
||||
th(scope="col") Listing
|
||||
th(scope="col") Title
|
||||
th(scope="col") Location
|
||||
th.text-center(scope="col") Remote?
|
||||
th.text-center(scope="col") Needed By
|
||||
tbody: tr(v-for="it in results" :key="it.listing.id")
|
||||
td: router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||
td {{it.listing.title}}
|
||||
td {{it.continent.name}} / {{it.listing.region}}
|
||||
td.text-center {{yesOrNo(it.listing.remoteWork)}}
|
||||
td.text-center(v-if="it.listing.neededBy") {{formatNeededBy(it.listing.neededBy)}}
|
||||
td.text-center(v-else) N/A
|
||||
p.pt-3(v-else-if="searched") No job listings found for the specified criteria
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, Ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { formatNeededBy } from "./"
|
||||
import { yesOrNo } from "@/App.vue"
|
||||
import api, { ListingForView, ListingSearch, LogOnSuccess } from "@/api"
|
||||
import { queryValue } from "@/router"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||
import ErrorList from "@/components/ErrorList.vue"
|
||||
import ListingSearchForm from "@/components/ListingSearchForm.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** Any errors encountered while retrieving data */
|
||||
const errors : Ref<string[]> = ref([])
|
||||
|
||||
/** Whether we are currently searching (retrieving data) */
|
||||
const searching = ref(false)
|
||||
|
||||
/** Whether a search has been performed on this page since it has been loaded */
|
||||
const searched = ref(false)
|
||||
|
||||
/** An empty set of search criteria */
|
||||
const emptyCriteria = {
|
||||
continentId: "",
|
||||
region: undefined,
|
||||
remoteWork: "",
|
||||
text: undefined
|
||||
}
|
||||
|
||||
/** The search criteria being built from the page */
|
||||
const criteria : Ref<ListingSearch> = ref(emptyCriteria)
|
||||
|
||||
/** The current search results */
|
||||
const results : Ref<ListingForView[]> = ref([])
|
||||
|
||||
/** Whether the search criteria should be collapsed */
|
||||
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||
|
||||
/** Set up the page to match its requested state */
|
||||
const setUpPage = async () => {
|
||||
if (queryValue(route, "searched") === "true") {
|
||||
searched.value = true
|
||||
try {
|
||||
searching.value = true
|
||||
// Hold variable for ensuring continent ID is not undefined here, but excluded from search payload
|
||||
const contId = queryValue(route, "continentId")
|
||||
const searchParams : ListingSearch = {
|
||||
continentId: contId === "" ? undefined : contId,
|
||||
region: queryValue(route, "region"),
|
||||
remoteWork: queryValue(route, "remoteWork") ?? "",
|
||||
text: queryValue(route, "text")
|
||||
}
|
||||
const searchResult = await api.listings.search(searchParams, store.state.user as LogOnSuccess)
|
||||
if (typeof searchResult === "string") {
|
||||
errors.value.push(searchResult)
|
||||
} else if (searchResult === undefined) {
|
||||
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||
} else {
|
||||
results.value = searchResult
|
||||
searchParams.continentId = searchParams.continentId ?? ""
|
||||
criteria.value = searchParams
|
||||
}
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
isCollapsed.value = searched.value && results.value.length > 0
|
||||
} else {
|
||||
searched.value = false
|
||||
criteria.value = emptyCriteria
|
||||
errors.value = []
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the page when the query string changes */
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
/** Show or hide the search parameter panel */
|
||||
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||
|
||||
/** Execute a search */
|
||||
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||
</script>
|
||||
@@ -1,136 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3(v-if="isNew") Add a Job Listing
|
||||
h3.pb-3(v-else) Edit Job Listing
|
||||
load-data(:load="retrieveData"): form.row.g-3
|
||||
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||
.form-floating
|
||||
input.form-control(type="text" id="title" :class="{ 'is-invalid': v$.title.$error }" maxlength="255"
|
||||
v-model="v$.title.$model" placeholder="The title for the job listing")
|
||||
#titleFeedback.invalid-feedback Please enter a title for the job listing
|
||||
label.jjj-required(for="title") Title
|
||||
.form-text No need to put location here; it will always be show to seekers with continent and region
|
||||
.col-12.col-sm-6.col-md-4
|
||||
continent-list(v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||
@touch="v$.continentId.$touch() || true")
|
||||
.col-12.col-sm-6.col-md-8
|
||||
.form-floating
|
||||
input.form-control(type="text" id="region" :class="{ 'is-invalid': v$.region.$error }" maxlength="255"
|
||||
v-model="v$.region.$model" placeholder="Country, state, geographic area, etc.")
|
||||
#regionFeedback.invalid-feedback Please enter a region
|
||||
label.jjj-required(for="region") Region
|
||||
.form-text Country, state, geographic area, etc.
|
||||
.col-12: .form-check
|
||||
input.form-check-input(type="checkbox" id="isRemote" v-model="v$.remoteWork.$model")
|
||||
label.form-check-label(for="isRemote") This opportunity is for remote work
|
||||
markdown-editor(id="description" label="Job Description" v-model:text="v$.text.$model" :isInvalid="v$.text.$error")
|
||||
.col-12.col-md-4: .form-floating
|
||||
input.form-control(type="date" id="neededBy" v-model="v$.neededBy.$model"
|
||||
placeholder="Date by which this position needs to be filled")
|
||||
label(for="neededBy") Needed By
|
||||
.col-12
|
||||
p.text-danger(v-if="v$.$error") Please correct the errors above
|
||||
button.btn.btn-primary(@click.prevent="saveListing(true)") #[icon(:icon="mdiContentSaveOutline")] Save
|
||||
maybe-save(:saveAction="doSave" :validator="v$")
|
||||
</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) router.push("/listings/mine")
|
||||
}
|
||||
}
|
||||
|
||||
/** Parameterless save function (used to save when navigating away) */
|
||||
const doSave = async () => await saveListing(false)
|
||||
</script>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
load-data(:load="retrieveListing")
|
||||
h3.pb-3 Expire Job Listing ({{listing.title}})
|
||||
p: em.
|
||||
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.
|
||||
form.row.g-3
|
||||
.col-12: .form-check
|
||||
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
|
||||
label.form-check-label(for="fromHere") This job was filled due to its listing here
|
||||
template(v-if="expiration.fromHere")
|
||||
.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.
|
||||
markdown-editor(id="successStory" label="Your Success Story" v-model:text="v$.successStory.$model")
|
||||
.col-12
|
||||
button.btn.btn-primary(@click.prevent="expireListing").
|
||||
#[icon(:icon="mdiTextBoxRemoveOutline")] Expire Listing
|
||||
maybe-save(:saveAction="doSave" :validator="v$")
|
||||
</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) {
|
||||
router.push("/listings/mine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** No-parameter save function (used for save-on-navigate) */
|
||||
const doSave = async () => await expireListing(false)
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
load-data(:load="retrieveListing")
|
||||
h3
|
||||
| {{it.listing.title}}
|
||||
.jjj-heading-label(v-if="it.listing.isExpired")
|
||||
| #[span.badge.bg-warning.text-dark Expired]
|
||||
template(v-if="it.listing.wasFilledHere") #[span.badge.bg-success Filled via Jobs, Jobs, Jobs]
|
||||
h4.pb-3.text-muted {{it.continent.name}} / {{it.listing.region}}
|
||||
p
|
||||
template(v-if="it.listing.neededBy").
|
||||
#[strong #[em NEEDED BY {{neededBy(it.listing.neededBy)}}]] •
|
||||
| Listed by #[a(:href="profileUrl" target="_blank") {{citizenName(citizen)}}]
|
||||
hr
|
||||
div(v-html="details")
|
||||
</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>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 My Job Listings
|
||||
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
|
||||
load-data(:load="getListings")
|
||||
h4.pb-2(v-if="expired.length > 0") Active Job Listings
|
||||
table.pb-3.table.table-sm.table-hover.pt-3(v-if="active.length > 0")
|
||||
thead: tr
|
||||
th(scope="col") Action
|
||||
th(scope="col") Title
|
||||
th(scope="col") Continent / Region
|
||||
th(scope="col") Created
|
||||
th(scope="col") Updated
|
||||
tbody: tr(v-for="it in active" :key="it.listing.id")
|
||||
td
|
||||
router-link(:to="`/listing/${it.listing.id}/edit`") Edit
|
||||
= " ~ "
|
||||
router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||
= " ~ "
|
||||
router-link(:to="`/listing/${it.listing.id}/expire`") Expire
|
||||
td {{it.listing.title}}
|
||||
td {{it.continent.name}} / {{it.listing.region}}
|
||||
td: full-date-time(:date="it.listing.createdOn")
|
||||
td: full-date-time(:date="it.listing.updatedOn")
|
||||
p.pb-3.fst-italic(v-else) You have no active job listings
|
||||
template(v-if="expired.length > 0")
|
||||
h4.pb-2 Expired Job Listings
|
||||
table.table.table-sm.table-hover.pt-3
|
||||
thead: tr
|
||||
th(scope="col") Action
|
||||
th(scope="col") Title
|
||||
th(scope="col") Filled Here?
|
||||
th(scope="col") Expired
|
||||
tbody: tr(v-for="it in expired" :key="it.listing.id")
|
||||
td
|
||||
router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||
td {{it.listing.title}}
|
||||
td {{yesOrNo(it.listing.wasFilledHere)}}
|
||||
td: full-date-time(:date="it.listing.updatedOn")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, Ref, ref } from "vue"
|
||||
import api, { ListingForView, LogOnSuccess } from "@/api"
|
||||
import { yesOrNo } from "@/App.vue"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import FullDateTime from "@/components/FullDateTime.vue"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The listings for the user */
|
||||
const listings : Ref<ListingForView[]> = ref([])
|
||||
|
||||
/** The active (non-expired) listings entered by this user */
|
||||
const active = computed(() => listings.value.filter(it => !it.listing.isExpired))
|
||||
|
||||
/** The expired listings entered by this user */
|
||||
const expired = computed(() => listings.value.filter(it => it.listing.isExpired))
|
||||
|
||||
/** Retrieve the job listing posted by the current citizen */
|
||||
const getListings = async (errors : string[]) => {
|
||||
const listResult = await api.listings.mine(store.state.user as LogOnSuccess)
|
||||
if (typeof listResult === "string") {
|
||||
errors.push(listResult)
|
||||
} else if (typeof listResult === "undefined") {
|
||||
errors.push("API call returned 404 (this should not happen)")
|
||||
} else {
|
||||
listings.value = listResult
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { format } from "date-fns"
|
||||
|
||||
/**
|
||||
* Format the needed by date for display
|
||||
*
|
||||
* @param neededBy The defined needed by date
|
||||
* @returns The date to display
|
||||
*/
|
||||
export function formatNeededBy (neededBy : string) : string {
|
||||
return format(Date.parse(`${neededBy}T00:00:00`), "PPP")
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 Search Profiles
|
||||
p(v-if="!searched").
|
||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||
profile-search-form(v-model="criteria" @search="doSearch")
|
||||
error-list(:errors="errors")
|
||||
p.pt-3(v-if="searching") Searching profiles…
|
||||
template(v-else)
|
||||
table.table.table-sm.table-hover.pt-3(v-if="results.length > 0")
|
||||
thead: tr
|
||||
th(scope="col") Profile
|
||||
th(scope="col") Name
|
||||
th.text-center(scope="col" v-if="wideDisplay") Seeking?
|
||||
th.text-center(scope="col") Remote?
|
||||
th.text-center(scope="col" v-if="wideDisplay") Full-Time?
|
||||
th(scope="col" v-if="wideDisplay") Last Updated
|
||||
tbody: tr(v-for="profile in results" :key="profile.citzenId")
|
||||
td: router-link(:to="`/profile/${profile.citizenId}/view`") View
|
||||
td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}}
|
||||
td.text-center(v-if="wideDisplay") {{yesOrNo(profile.seekingEmployment)}}
|
||||
td.text-center {{yesOrNo(profile.remoteWork)}}
|
||||
td.text-center(v-if="wideDisplay") {{yesOrNo(profile.fullTime)}}
|
||||
td(v-if="wideDisplay"): full-date(:date="profile.lastUpdatedOn")
|
||||
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||
|
||||
import { yesOrNo } from "@/App.vue"
|
||||
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
|
||||
import { queryValue } from "@/router"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||
import ErrorList from "@/components/ErrorList.vue"
|
||||
import FullDate from "@/components/FullDate.vue"
|
||||
import ProfileSearchForm from "@/components/profile/SearchForm.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||
|
||||
/** Any errors encountered while retrieving data */
|
||||
const errors : Ref<string[]> = ref([])
|
||||
|
||||
/** Whether we are currently searching (retrieving data) */
|
||||
const searching = ref(false)
|
||||
|
||||
/** Whether a search has been performed on this page since it has been loaded */
|
||||
const searched = ref(false)
|
||||
|
||||
/** An empty set of search criteria */
|
||||
const emptyCriteria = {
|
||||
continentId: "",
|
||||
skill: undefined,
|
||||
bioExperience: undefined,
|
||||
remoteWork: ""
|
||||
}
|
||||
|
||||
/** The search criteria being built from the page */
|
||||
const criteria : Ref<ProfileSearch> = ref(emptyCriteria)
|
||||
|
||||
/** The current search results */
|
||||
const results : Ref<ProfileSearchResult[]> = ref([])
|
||||
|
||||
/** Whether the search criteria should be collapsed */
|
||||
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||
|
||||
/** Hide certain columns if the display is too narrow */
|
||||
const wideDisplay = breakpoints.greater("sm")
|
||||
|
||||
/** Set up the page to match its requested state */
|
||||
const setUpPage = async () => {
|
||||
if (queryValue(route, "searched") === "true") {
|
||||
searched.value = true
|
||||
try {
|
||||
searching.value = true
|
||||
// Hold variable for ensuring continent ID is not undefined here, but excluded from search payload
|
||||
const contId = queryValue(route, "continentId")
|
||||
const searchParams : ProfileSearch = {
|
||||
continentId: contId === "" ? undefined : contId,
|
||||
skill: queryValue(route, "skill"),
|
||||
bioExperience: queryValue(route, "bioExperience"),
|
||||
remoteWork: queryValue(route, "remoteWork") ?? ""
|
||||
}
|
||||
const searchResult = await api.profile.search(searchParams, store.state.user as LogOnSuccess)
|
||||
if (typeof searchResult === "string") {
|
||||
errors.value.push(searchResult)
|
||||
} else if (searchResult === undefined) {
|
||||
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||
} else {
|
||||
results.value = searchResult
|
||||
searchParams.continentId = searchParams.continentId ?? ""
|
||||
criteria.value = searchParams
|
||||
}
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
isCollapsed.value = searched.value && results.value.length > 0
|
||||
} else {
|
||||
searched.value = false
|
||||
criteria.value = emptyCriteria
|
||||
errors.value = []
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the page when the query string changes */
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
/** Show and hide the search parameter panel */
|
||||
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||
|
||||
/** Execute a search */
|
||||
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||
</script>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
page-title(:title="title")
|
||||
load-data(:load="retrieveProfile")
|
||||
h2
|
||||
a(:href="it.citizen.profileUrl" target="_blank") {{citizenName(it.citizen)}}
|
||||
.jjj-heading-label(v-if="it.profile.seekingEmployment")
|
||||
| #[span.badge.bg-dark Currently Seeking Employment]
|
||||
h4.pb-3 {{it.continent.name}}, {{it.profile.region}}
|
||||
p(v-html="workTypes")
|
||||
hr
|
||||
div(v-html="bioHtml")
|
||||
template(v-if="it.profile.skills.length > 0")
|
||||
hr
|
||||
h4.pb-3 Skills
|
||||
ul
|
||||
li(v-for="(skill, idx) in it.profile.skills" :key="idx").
|
||||
{{skill.description}}#[template(v-if="skill.notes") ({{skill.notes}})]
|
||||
template(v-if="it.profile.experience")
|
||||
hr
|
||||
h4.pb-3 Experience / Employment History
|
||||
div(v-html="expHtml")
|
||||
template(v-if="user.citizenId === it.citizen.id")
|
||||
br
|
||||
br
|
||||
router-link.btn.btn-primary(to="/citizen/profile") #[icon(:icon="mdiPencil")] Edit Your Profile
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { mdiPencil } from "@mdi/js"
|
||||
|
||||
import api, { LogOnSuccess, ProfileForView } 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 profile */
|
||||
const it : Ref<ProfileForView | undefined> = ref(undefined)
|
||||
|
||||
/** The work types for the top of the page */
|
||||
const workTypes = computed(() => {
|
||||
const parts : string[] = []
|
||||
if (it.value) {
|
||||
const p = it.value.profile
|
||||
parts.push(`${p.fullTime ? "I" : "Not i"}nterested in full-time employment`)
|
||||
parts.push(`${p.remoteWork ? "I" : "Not i"}nterested in remote opportunities`)
|
||||
}
|
||||
return parts.join(" • ")
|
||||
})
|
||||
|
||||
/** Retrieve the profile and supporting data */
|
||||
const retrieveProfile = async (errors : string[]) => {
|
||||
const profileResp = await api.profile.retreiveForView(route.params.id as string, user)
|
||||
if (typeof profileResp === "string") {
|
||||
errors.push(profileResp)
|
||||
} else if (typeof profileResp === "undefined") {
|
||||
errors.push("Profile not found")
|
||||
} else {
|
||||
it.value = profileResp
|
||||
store.commit(Mutations.SetTitle, `Employment profile for ${citizenName(profileResp.citizen)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The HTML version of the citizen's professional biography */
|
||||
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))
|
||||
|
||||
/** The HTML version of the citizens Experience section */
|
||||
const expHtml = computed(() => toHtml(it.value?.profile.experience ?? ""))
|
||||
</script>
|
||||
@@ -1,112 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 People Seeking Work
|
||||
p(v-if="!searched").
|
||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||
profile-public-search-form(v-model="criteria" @search="doSearch")
|
||||
error-list(:errors="errors")
|
||||
p(v-if="searching") Searching profiles…
|
||||
template(v-else)
|
||||
template(v-if="results.length > 0")
|
||||
p.pb-3.pt-3.
|
||||
These profiles match your search criteria. To learn more about these people, join the merry band of human
|
||||
resources in the #[a(href="https://noagendashow.net" target="_blank") No Agenda] tribe!
|
||||
table.table.table-sm.table-hover
|
||||
thead: tr
|
||||
th(scope="col") Continent
|
||||
th.text-center(scope="col") Region
|
||||
th.text-center(scope="col") Remote?
|
||||
th.text-center(scope="col") Skills
|
||||
tbody: tr(v-for="(profile, idx) in results" :key="idx")
|
||||
td {{profile.continent}}
|
||||
td {{profile.region}}
|
||||
td.text-center {{yesOrNo(profile.remoteWork)}}
|
||||
td: template(v-for="(skill, idx) in profile.skills" :key="idx") {{skill}}#[br]
|
||||
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { yesOrNo } from "@/App.vue"
|
||||
import api, { PublicSearch, PublicSearchResult } from "@/api"
|
||||
import { queryValue } from "@/router"
|
||||
|
||||
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||
import ErrorList from "@/components/ErrorList.vue"
|
||||
import ProfilePublicSearchForm from "@/components/profile/PublicSearchForm.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** Whether a search has been performed */
|
||||
const searched = ref(false)
|
||||
|
||||
/** Indicates whether a request for matching profiles is in progress */
|
||||
const searching = ref(false)
|
||||
|
||||
/** Error messages encountered while searching for profiles */
|
||||
const errors : Ref<string[]> = ref([])
|
||||
|
||||
/** An empty set of search criteria */
|
||||
const emptyCriteria = {
|
||||
continentId: "",
|
||||
region: undefined,
|
||||
skill: undefined,
|
||||
remoteWork: ""
|
||||
}
|
||||
|
||||
/** The search criteria being built from the page */
|
||||
const criteria : Ref<PublicSearch> = ref(emptyCriteria)
|
||||
|
||||
/** The search results */
|
||||
const results : Ref<PublicSearchResult[]> = ref([])
|
||||
|
||||
/** Whether the search results are collapsed */
|
||||
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||
|
||||
/** Set up the page to match its requested state */
|
||||
const setUpPage = async () => {
|
||||
if (queryValue(route, "searched") === "true") {
|
||||
searched.value = true
|
||||
try {
|
||||
searching.value = true
|
||||
const contId = queryValue(route, "continentId")
|
||||
const searchParams : PublicSearch = {
|
||||
continentId: contId === "" ? undefined : contId,
|
||||
region: queryValue(route, "region"),
|
||||
skill: queryValue(route, "skill"),
|
||||
remoteWork: queryValue(route, "remoteWork") ?? ""
|
||||
}
|
||||
const searchResult = await api.profile.publicSearch(searchParams)
|
||||
if (typeof searchResult === "string") {
|
||||
errors.value.push(searchResult)
|
||||
} else if (searchResult === undefined) {
|
||||
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||
} else {
|
||||
results.value = searchResult
|
||||
searchParams.continentId = searchParams.continentId ?? ""
|
||||
criteria.value = searchParams
|
||||
}
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
isCollapsed.value = searched.value && results.value.length > 0
|
||||
} else {
|
||||
searched.value = false
|
||||
criteria.value = emptyCriteria
|
||||
errors.value = []
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the page when the query string changes */
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
/** Open and closed the search parameter panel */
|
||||
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||
|
||||
/** Execute a search */
|
||||
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 Account Deletion Options
|
||||
h4.pb-3 Option 1 – Delete Your Profile
|
||||
p.
|
||||
Utilizing this option will remove your current employment profile and skills. This will preserve any success stories
|
||||
you may have written, and preserves this application’s knowledge of you. This is what you want to use if you
|
||||
want to clear out your profile and start again (and remove the current one from others’ view).
|
||||
p.text-center: button.btn.btn-danger(@click.prevent="deleteProfile") Delete Your Profile
|
||||
hr
|
||||
h4.pb-3 Option 2 – Delete Your Account
|
||||
p.
|
||||
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
||||
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
||||
#[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
||||
p: em.
|
||||
(This will not revoke this application’s permissions on Mastodon; you will have to remove this yourself. The
|
||||
confirmation message has a link where you can do this; once the page loads, find the
|
||||
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.)
|
||||
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import api, { LogOnSuccess } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore, Actions, Mutations } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** Delete the profile only; redirect to home page on success */
|
||||
const deleteProfile = async () => {
|
||||
const resp = await api.profile.delete(user)
|
||||
if (typeof resp === "string") {
|
||||
toastError(resp, "Deleting Profile")
|
||||
} else {
|
||||
toastSuccess("Profile Deleted Successfully")
|
||||
router.push("/citizen/dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete everything pertaining to the user's account */
|
||||
const deleteAccount = async () => {
|
||||
const citizenResp = await api.citizen.retrieve(user.citizenId, user)
|
||||
if (typeof citizenResp === "string") {
|
||||
toastError(citizenResp, "retrieving citizen")
|
||||
} else if (typeof citizenResp === "undefined") {
|
||||
toastError("Could not retrieve citizen record", undefined)
|
||||
} else {
|
||||
const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
|
||||
if (typeof instance === "undefined") {
|
||||
toastError("Could not retrieve instance", undefined)
|
||||
} else {
|
||||
const resp = await api.citizen.delete(user)
|
||||
if (typeof resp === "string") {
|
||||
toastError(resp, "Deleting Account")
|
||||
} else {
|
||||
store.commit(Mutations.ClearUser)
|
||||
toastSuccess("Account Deleted Successfully")
|
||||
router.push(`/so-long/success/${instance.abbr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 Account Deletion Success
|
||||
p.
|
||||
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
||||
application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
|
||||
#[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty
|
||||
account without prompting you further.
|
||||
p Thank you for participating, and thank you for your courage. #GitmoNation
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
/** The abbreviation of the instance from which the deleted user had authorized access */
|
||||
const abbr = route.params.abbr as string
|
||||
|
||||
/** The URL of that instance */
|
||||
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
@@ -1,109 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 {{title}}
|
||||
load-data(:load="retrieveStory")
|
||||
p(v-if="isNew").
|
||||
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
|
||||
about it below! #[em (These will be visible to other users, but not to the general public.)]
|
||||
form.row.g-3
|
||||
.col-12: .form-check
|
||||
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
|
||||
label.form-check-label(for="fromHere") I found my employment here
|
||||
markdown-editor(id="story" label="The Success Story" v-model:text="v$.story.$model")
|
||||
.col-12
|
||||
button.btn.btn-primary(type="submit" @click.prevent="saveStory(true)").
|
||||
#[icon(:icon="mdiContentSaveOutline")] Save
|
||||
p(v-if="isNew"): em (Saving this will set “Seeking Employment” to “No” on your profile.)
|
||||
maybe-save(:saveAction="doSave" :validator="v$")
|
||||
</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 api, { LogOnSuccess, StoryForm } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { Mutations, 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 story being edited */
|
||||
const id = route.params.id as string
|
||||
|
||||
/** Whether this is a new story */
|
||||
const isNew = computed(() => id === "new")
|
||||
|
||||
/** The form for editing the story */
|
||||
const story = reactive(new StoryForm())
|
||||
|
||||
/** Validator rules */
|
||||
const rules = computed(() => ({
|
||||
fromHere: { },
|
||||
story: { }
|
||||
}))
|
||||
|
||||
/** The validator */
|
||||
const v$ = useVuelidate(rules, story, { $lazy: true })
|
||||
|
||||
/** Retrieve the specified story */
|
||||
const retrieveStory = async (errors : string[]) => {
|
||||
if (isNew.value) {
|
||||
story.id = "new"
|
||||
store.commit(Mutations.SetTitle, "Tell Your Success Story")
|
||||
} else {
|
||||
const storyResult = await api.success.retrieve(id, user)
|
||||
if (typeof storyResult === "string") {
|
||||
errors.push(storyResult)
|
||||
} else if (typeof storyResult === "undefined") {
|
||||
errors.push("Story not found")
|
||||
} else if (storyResult.citizenId !== user.citizenId) {
|
||||
errors.push("Quit messing around")
|
||||
} else {
|
||||
story.id = storyResult.id
|
||||
story.fromHere = storyResult.fromHere
|
||||
story.story = storyResult.story ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the success story */
|
||||
const saveStory = async (navigate : boolean) => {
|
||||
const saveResult = await api.success.save(story, user)
|
||||
if (typeof saveResult === "string") {
|
||||
toastError(saveResult, "saving success story")
|
||||
} else {
|
||||
if (isNew.value) {
|
||||
const foundResult = await api.profile.markEmploymentFound(user)
|
||||
if (typeof foundResult === "string") {
|
||||
toastError(foundResult, "clearing employment flag")
|
||||
} else {
|
||||
toastSuccess("Success Story saved and Seeking Employment flag cleared successfully")
|
||||
v$.value.$reset()
|
||||
if (navigate) {
|
||||
router.push("/success-story/list")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toastSuccess("Success Story saved successfully")
|
||||
v$.value.$reset()
|
||||
if (navigate) {
|
||||
router.push("/success-story/list")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** No-parameter save function (used for save-on-navigate) */
|
||||
const doSave = async () => await saveStory(false)
|
||||
</script>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
h3.pb-3 Success Stories
|
||||
load-data(:load="retrieveStories")
|
||||
table.table.table-sm.table-hover(v-if="stories?.length > 0")
|
||||
thead: tr
|
||||
th(scope="col") Story
|
||||
th(scope="col") From
|
||||
th(scope="col") Found Here?
|
||||
th(scope="col") Recorded On
|
||||
tbody: tr(v-for="story in stories" :key="story.id")
|
||||
td
|
||||
router-link(v-if="story.hasStory" :to="`/success-story/${story.id}/view`") View
|
||||
em(v-else) None
|
||||
template(v-if="story.citizenId === user.citizenId")
|
||||
| ~ #[router-link(:to="`/success-story/${story.id}/edit`") Edit]
|
||||
td {{story.citizenName}}
|
||||
td
|
||||
strong(v-if="story.fromHere") Yes
|
||||
template(v-else) No
|
||||
td: full-date(:date="story.recordedOn")
|
||||
p(v-else) There are no success stories recorded #[em (yet)]
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, Ref } from "vue"
|
||||
import api, { LogOnSuccess, StoryEntry } from "@/api"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import FullDate from "@/components/FullDate.vue"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** The success stories to be displayed */
|
||||
const stories : Ref<StoryEntry[] | undefined> = ref(undefined)
|
||||
|
||||
/** Get all currently recorded stories */
|
||||
const retrieveStories = async (errors : string[]) => {
|
||||
const listResult = await api.success.list(user)
|
||||
if (typeof listResult === "string") {
|
||||
errors.push(listResult)
|
||||
} else if (typeof listResult === "undefined") {
|
||||
stories.value = []
|
||||
} else {
|
||||
stories.value = listResult
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
load-data(:load="retrieveStory")
|
||||
h3
|
||||
| {{citizenName}}’s Success Story
|
||||
.jjj-heading-label(v-if="story.fromHere")
|
||||
| #[span.badge.bg-success Via {{profileOrListing}} on Jobs, Jobs, Jobs]
|
||||
h4.pb-3.text-muted: full-date-time(:date="story.recordedOn")
|
||||
div(v-if="story.story" v-html="successStory")
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, Ref, ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
import api, { LogOnSuccess, Success } from "@/api"
|
||||
import { citizenName as citName } from "@/App.vue"
|
||||
import { toHtml } from "@/markdown"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import FullDateTime from "@/components/FullDateTime.vue"
|
||||
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 story to be displayed */
|
||||
const story : Ref<Success | undefined> = ref(undefined)
|
||||
|
||||
/** The citizen's name (real, display, or Mastodon, whichever is found first) */
|
||||
const citizenName = ref("")
|
||||
|
||||
/** Retrieve the success story */
|
||||
const retrieveStory = async (errors : string []) => {
|
||||
const storyResponse = await api.success.retrieve(route.params.id as string, user)
|
||||
if (typeof storyResponse === "string") {
|
||||
errors.push(storyResponse)
|
||||
return
|
||||
}
|
||||
if (typeof storyResponse === "undefined") {
|
||||
errors.push("Success story not found")
|
||||
return
|
||||
}
|
||||
story.value = storyResponse
|
||||
const citResponse = await api.citizen.retrieve(story.value.citizenId, user)
|
||||
if (typeof citResponse === "string") {
|
||||
errors.push(citResponse)
|
||||
} else if (typeof citResponse === "undefined") {
|
||||
errors.push("Citizen not found")
|
||||
} else {
|
||||
citizenName.value = citName(citResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this success is from an employment profile or a job listing */
|
||||
const profileOrListing = computed(() => story.value?.source === "profile" ? "employment profile" : "job listing")
|
||||
|
||||
/** The HTML success story */
|
||||
const successStory = computed(() => toHtml(story.value?.story ?? ""))
|
||||
</script>
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
module.exports = {
|
||||
transpileDependencies: [
|
||||
'vuetify'
|
||||
],
|
||||
outputDir: '../Server/wwwroot',
|
||||
configureWebpack: {
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
type: "javascript/auto"
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/JobsJobsJobs/Application/ApiHandlers.fs
Normal file
24
src/JobsJobsJobs/Application/ApiHandlers.fs
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Route handlers for Giraffe endpoints
|
||||
module JobsJobsJobs.Api.Handlers
|
||||
|
||||
open System.IO
|
||||
open Giraffe
|
||||
open JobsJobsJobs.Common.Handlers
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
// POST: /api/markdown-preview
|
||||
let markdownPreview : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let _ = ctx.Request.Body.Seek(0L, SeekOrigin.Begin)
|
||||
use reader = new StreamReader (ctx.Request.Body)
|
||||
let! preview = reader.ReadToEndAsync ()
|
||||
return! htmlString (MarkdownString.toHtml (Text preview)) next ctx
|
||||
}
|
||||
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All API endpoints
|
||||
let endpoints =
|
||||
subRoute "/api" [
|
||||
POST [ route "/markdown-preview" markdownPreview ]
|
||||
]
|
||||
85
src/JobsJobsJobs/Application/App.fs
Normal file
85
src/JobsJobsJobs/Application/App.fs
Normal file
@@ -0,0 +1,85 @@
|
||||
/// The main web server application for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.App
|
||||
|
||||
open System
|
||||
open System.Text
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open JobsJobsJobs.Common.Data
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.AspNetCore.HttpOverrides
|
||||
open Microsoft.Extensions.Caching.Distributed
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
open NodaTime
|
||||
|
||||
|
||||
/// Enable buffering on the request body
|
||||
type BufferedBodyMiddleware (next : RequestDelegate) =
|
||||
|
||||
member _.InvokeAsync (ctx : HttpContext) = task {
|
||||
ctx.Request.EnableBuffering ()
|
||||
return! next.Invoke ctx
|
||||
}
|
||||
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
|
||||
let builder = WebApplication.CreateBuilder args
|
||||
let svc = builder.Services
|
||||
|
||||
let _ = svc.AddGiraffe ()
|
||||
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
||||
let _ = svc.AddLogging ()
|
||||
let _ = svc.AddCors ()
|
||||
let _ = svc.AddSingleton<Json.ISerializer> (SystemTextJson.Serializer Json.options)
|
||||
let _ = svc.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||
let _ = svc.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(fun o ->
|
||||
o.ExpireTimeSpan <- TimeSpan.FromMinutes 60
|
||||
o.SlidingExpiration <- true
|
||||
o.AccessDeniedPath <- "/error/not-authorized"
|
||||
o.ClaimsIssuer <- "https://noagendacareers.com")
|
||||
let _ = svc.AddAuthorization ()
|
||||
let _ = svc.AddAntiforgery ()
|
||||
|
||||
// Set up the data store
|
||||
let cfg = svc.BuildServiceProvider().GetRequiredService<IConfiguration> ()
|
||||
let _ = setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
|
||||
let _ = svc.AddSingleton<IDistributedCache> (fun _ -> DistributedCache () :> IDistributedCache)
|
||||
let _ = svc.AddSession(fun opts ->
|
||||
opts.IdleTimeout <- TimeSpan.FromMinutes 60
|
||||
opts.Cookie.HttpOnly <- true
|
||||
opts.Cookie.IsEssential <- true)
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
// Unify the endpoints from all features
|
||||
let endpoints = [
|
||||
Citizens.Handlers.endpoints
|
||||
yield! Home.Handlers.endpoints
|
||||
yield! Listings.Handlers.endpoints
|
||||
Profiles.Handlers.endpoints
|
||||
SuccessStories.Handlers.endpoints
|
||||
Api.Handlers.endpoints
|
||||
]
|
||||
|
||||
let _ = app.UseForwardedHeaders ()
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseMiddleware<BufferedBodyMiddleware> ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseAuthorization ()
|
||||
let _ = app.UseSession ()
|
||||
let _ = app.UseGiraffeErrorHandler Common.Handlers.Error.unexpectedError
|
||||
let _ = app.UseEndpoints (fun e -> e.MapGiraffeEndpoints endpoints |> ignore)
|
||||
|
||||
app.Run ()
|
||||
|
||||
0
|
||||
28
src/JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj
Normal file
28
src/JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="ApiHandlers.fs" />
|
||||
<Compile Include="App.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include=".\wwwroot" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Citizens\JobsJobsJobs.Citizens.fsproj" />
|
||||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
<ProjectReference Include="..\Home\JobsJobsJobs.Home.fsproj" />
|
||||
<ProjectReference Include="..\Listings\JobsJobsJobs.Listings.fsproj" />
|
||||
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
|
||||
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
src/JobsJobsJobs/Application/appsettings.json
Normal file
8
src/JobsJobsJobs/Application/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"JobsJobsJobs.Api.Handlers.Citizen": "Information",
|
||||
"Microsoft.AspNetCore.StaticFiles": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
303
src/JobsJobsJobs/Application/wwwroot/script.js
Normal file
303
src/JobsJobsJobs/Application/wwwroot/script.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/** Script for Jobs, Jobs, Jobs */
|
||||
this.jjj = {
|
||||
/**
|
||||
* Play an audio file
|
||||
* @param {HTMLElement} elt The element which was clicked
|
||||
*/
|
||||
playFile(elt) {
|
||||
elt.querySelector("audio").play()
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the offcanvas menu if it displayed
|
||||
*/
|
||||
hideMenu() {
|
||||
/** @type {HTMLElement} */
|
||||
const menu = document.querySelector(".jjj-mobile-menu")
|
||||
if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide()
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a message via an alert
|
||||
* @param {string} message The message to show
|
||||
*/
|
||||
showAlert (message) {
|
||||
const [level, msg] = message.split("|||")
|
||||
|
||||
/** @type {HTMLTemplateElement} */
|
||||
const alertTemplate = document.getElementById("alertTemplate")
|
||||
/** @type {HTMLDivElement} */
|
||||
const alert = alertTemplate.content.firstElementChild.cloneNode(true)
|
||||
alert.classList.add(`alert-${level === "error" ? "danger" : level}`)
|
||||
|
||||
const prefix = level === "success" ? "" : `<strong>${level.toUpperCase()}: </strong>`
|
||||
alert.querySelector("p").innerHTML = `${prefix}${msg}`
|
||||
|
||||
const alerts = document.getElementById("alerts")
|
||||
alerts.appendChild(alert)
|
||||
alerts.scrollIntoView()
|
||||
},
|
||||
|
||||
/**
|
||||
* The time zone of the current browser
|
||||
* @type {string}
|
||||
*/
|
||||
timeZone: undefined,
|
||||
|
||||
/**
|
||||
* Derive the time zone from the current browser
|
||||
*/
|
||||
deriveTimeZone () {
|
||||
try {
|
||||
this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone
|
||||
} catch (_) { }
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up the onClick event for the preview button
|
||||
* @param {string} editorId The ID of the editor to wire up
|
||||
*/
|
||||
markdownOnLoad(editorId) {
|
||||
document.getElementById(`${editorId}PreviewButton`).addEventListener("click", () => { this.showPreview(editorId) })
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a preview of the Markdown in the given editor
|
||||
* @param {string} editorId The ID of the Markdown editor whose preview should be shown
|
||||
*/
|
||||
async showPreview(editorId) {
|
||||
/** @type {HTMLButtonElement} */
|
||||
const editBtn = document.getElementById(`${editorId}EditButton`)
|
||||
/** @type {HTMLDivElement} */
|
||||
const editDiv = document.getElementById(`${editorId}Edit`)
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
const editor = document.getElementById(editorId)
|
||||
/** @type {HTMLButtonElement} */
|
||||
const previewBtn = document.getElementById(`${editorId}PreviewButton`)
|
||||
/** @type {HTMLDivElement} */
|
||||
const previewDiv = document.getElementById(`${editorId}Preview`)
|
||||
|
||||
editBtn.classList.remove("btn-primary")
|
||||
editBtn.classList.add("btn-outline-secondary")
|
||||
editBtn.addEventListener("click", () => { this.showEditor(editorId) })
|
||||
previewBtn.classList.remove("btn-outline-secondary")
|
||||
previewBtn.classList.add("btn-primary")
|
||||
previewBtn.removeEventListener("click", () => { this.showPreview(editorId) })
|
||||
|
||||
const preview = await fetch("/api/markdown-preview", { method: "POST", body: editor.value })
|
||||
let text
|
||||
if (preview.ok) {
|
||||
text = await preview.text()
|
||||
} else {
|
||||
text = `<p class="text-danger"><strong> ERROR ${preview.status}</strong> – ${preview.statusText}`
|
||||
}
|
||||
previewDiv.innerHTML = text
|
||||
|
||||
editDiv.classList.remove("jjj-shown")
|
||||
editDiv.classList.add("jjj-not-shown")
|
||||
previewDiv.classList.remove("jjj-not-shown")
|
||||
previewDiv.classList.add("jjj-shown")
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the Markdown editor (hides preview)
|
||||
* @param {string} editorId The ID of the Markdown editor to show
|
||||
*/
|
||||
showEditor(editorId) {
|
||||
/** @type {HTMLButtonElement} */
|
||||
const editBtn = document.getElementById(`${editorId}EditButton`)
|
||||
/** @type {HTMLDivElement} */
|
||||
const editDiv = document.getElementById(`${editorId}Edit`)
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
const editor = document.getElementById(editorId)
|
||||
/** @type {HTMLButtonElement} */
|
||||
const previewBtn = document.getElementById(`${editorId}PreviewButton`)
|
||||
/** @type {HTMLDivElement} */
|
||||
const previewDiv = document.getElementById(`${editorId}Preview`)
|
||||
|
||||
previewBtn.classList.remove("btn-primary")
|
||||
previewBtn.classList.add("btn-outline-secondary")
|
||||
this.markdownOnLoad(editorId)
|
||||
editBtn.classList.remove("btn-outline-secondary")
|
||||
editBtn.classList.add("btn-primary")
|
||||
editBtn.removeEventListener("click", () => { this.showEditor(editorId) })
|
||||
|
||||
previewDiv.classList.remove("jjj-shown")
|
||||
previewDiv.classList.add("jjj-not-shown")
|
||||
previewDiv.innerHTML = ""
|
||||
editDiv.classList.remove("jjj-not-shown")
|
||||
editDiv.classList.add("jjj-shown")
|
||||
},
|
||||
|
||||
citizen: {
|
||||
|
||||
/**
|
||||
* The next index for a newly-added contact
|
||||
* @type {number}
|
||||
*/
|
||||
nextIndex: 0,
|
||||
|
||||
/**
|
||||
* Add a contact to the account form
|
||||
*/
|
||||
addContact() {
|
||||
const next = this.nextIndex
|
||||
|
||||
/** @type {HTMLTemplateElement} */
|
||||
const newContactTemplate = document.getElementById("newContact")
|
||||
/** @type {HTMLDivElement} */
|
||||
const newContact = newContactTemplate.content.firstElementChild.cloneNode(true)
|
||||
newContact.setAttribute("id", `contactRow${next}`)
|
||||
|
||||
const cols = newContact.children
|
||||
// Button column
|
||||
cols[0].querySelector("button").setAttribute("onclick", `jjj.citizen.removeContact(${next})`)
|
||||
// Contact Type column
|
||||
const typeField = cols[1].querySelector("select")
|
||||
typeField.setAttribute("id", `contactType${next}`)
|
||||
typeField.setAttribute("name", `Contacts[${this.nextIndex}].ContactType`)
|
||||
cols[1].querySelector("label").setAttribute("for", `contactType${next}`)
|
||||
// Name column
|
||||
const nameField = cols[2].querySelector("input")
|
||||
nameField.setAttribute("id", `contactName${next}`)
|
||||
nameField.setAttribute("name", `Contacts[${this.nextIndex}].Name`)
|
||||
cols[2].querySelector("label").setAttribute("for", `contactName${next}`)
|
||||
if (next > 0) cols[2].querySelector("div.form-text").remove()
|
||||
// Value column
|
||||
const valueField = cols[3].querySelector("input")
|
||||
valueField.setAttribute("id", `contactValue${next}`)
|
||||
valueField.setAttribute("name", `Contacts[${this.nextIndex}].Value`)
|
||||
cols[3].querySelector("label").setAttribute("for", `contactName${next}`)
|
||||
if (next > 0) cols[3].querySelector("div.form-text").remove()
|
||||
// Is Public column
|
||||
const isPublicField = cols[4].querySelector("input")
|
||||
isPublicField.setAttribute("id", `contactIsPublic${next}`)
|
||||
isPublicField.setAttribute("name", `Contacts[${this.nextIndex}].IsPublic`)
|
||||
cols[4].querySelector("label").setAttribute("for", `contactIsPublic${next}`)
|
||||
|
||||
// Add the row
|
||||
const contacts = document.querySelectorAll("div[id^=contactRow]")
|
||||
const sibling = contacts.length > 0 ? contacts[contacts.length - 1] : newContactTemplate
|
||||
sibling.insertAdjacentElement('afterend', newContact)
|
||||
|
||||
this.nextIndex++
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a contact row from the profile form
|
||||
* @param {number} idx The index of the contact row to remove
|
||||
*/
|
||||
removeContact(idx) {
|
||||
document.getElementById(`contactRow${idx}`).remove()
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a comparison validation between a password and a "confirm password" field
|
||||
* @param {string} pwId The ID for the password field
|
||||
* @param {string} confirmId The ID for the "confirm password" field
|
||||
* @param {boolean} isRequired Whether these fields are required
|
||||
*/
|
||||
validatePasswords(pwId, confirmId, isRequired) {
|
||||
const pw = document.getElementById(pwId)
|
||||
const pwConfirm = document.getElementById(confirmId)
|
||||
pwConfirm.addEventListener("input", () => {
|
||||
if (!pw.validity.valid) {
|
||||
pwConfirm.setCustomValidity("")
|
||||
} else if ((!pwConfirm.validity.valueMissing || !isRequired) && pw.value !== pwConfirm.value) {
|
||||
pwConfirm.setCustomValidity("Confirmation password does not match")
|
||||
} else {
|
||||
pwConfirm.setCustomValidity("")
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
profile: {
|
||||
|
||||
/**
|
||||
* The next index for a newly-added skill
|
||||
* @type {number}
|
||||
*/
|
||||
nextIndex: 0,
|
||||
|
||||
/**
|
||||
* Add a skill to the profile form
|
||||
*/
|
||||
addSkill() {
|
||||
const next = this.nextIndex
|
||||
|
||||
/** @type {HTMLTemplateElement} */
|
||||
const newSkillTemplate = document.getElementById("newSkill")
|
||||
/** @type {HTMLDivElement} */
|
||||
const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true)
|
||||
newSkill.setAttribute("id", `skillRow${next}`)
|
||||
|
||||
const cols = newSkill.children
|
||||
// Button column
|
||||
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill(${next})`)
|
||||
// Skill column
|
||||
const skillField = cols[1].querySelector("input")
|
||||
skillField.setAttribute("id", `skillDesc${next}`)
|
||||
skillField.setAttribute("name", `Skills[${this.nextIndex}].Description`)
|
||||
cols[1].querySelector("label").setAttribute("for", `skillDesc${next}`)
|
||||
if (this.nextIndex > 0) cols[1].querySelector("div.form-text").remove()
|
||||
// Notes column
|
||||
const notesField = cols[2].querySelector("input")
|
||||
notesField.setAttribute("id", `skillNotes${next}`)
|
||||
notesField.setAttribute("name", `Skills[${this.nextIndex}].Notes`)
|
||||
cols[2].querySelector("label").setAttribute("for", `skillNotes${next}`)
|
||||
if (this.nextIndex > 0) cols[2].querySelector("div.form-text").remove()
|
||||
|
||||
// Add the row
|
||||
const skills = document.querySelectorAll("div[id^=skillRow]")
|
||||
const sibling = skills.length > 0 ? skills[skills.length - 1] : newSkillTemplate
|
||||
sibling.insertAdjacentElement('afterend', newSkill)
|
||||
|
||||
this.nextIndex++
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a skill row from the profile form
|
||||
* @param {number} idx The index of the skill row to remove
|
||||
*/
|
||||
removeSkill(idx) {
|
||||
document.getElementById(`skillRow${idx}`).remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.on("htmx:configRequest", function (evt) {
|
||||
// Send the user's current time zone so that we can display local time
|
||||
if (jjj.timeZone) {
|
||||
evt.detail.headers["X-Time-Zone"] = jjj.timeZone
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on("htmx:responseError", function (evt) {
|
||||
/** @type {XMLHttpRequest} */
|
||||
const xhr = evt.detail.xhr
|
||||
jjj.showAlert(`error|||${xhr.status}: ${xhr.statusText}`)
|
||||
})
|
||||
|
||||
jjj.deriveTimeZone()
|
||||
248
src/JobsJobsJobs/Application/wwwroot/style.css
Normal file
248
src/JobsJobsJobs/Application/wwwroot/style.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/* Overall styling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
a:link,
|
||||
a:visited {
|
||||
text-decoration: none;
|
||||
}
|
||||
a:not(.btn):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
label.jjj-required::after {
|
||||
color: red;
|
||||
content: ' *';
|
||||
}
|
||||
label[for]:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.jjj-heading-label {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.material-icons {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@media print {
|
||||
.jjj-hide-from-printer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Material Design Icon / Bootstrap styling */
|
||||
.mdi::before {
|
||||
font-size: 24px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.btn .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
}
|
||||
.btn-xs .mdi::before {
|
||||
font-size: 18px;
|
||||
top: 3px;
|
||||
}
|
||||
.btn-sm .mdi::before {
|
||||
font-size: 18px;
|
||||
top: 3px;
|
||||
}
|
||||
.dropdown-menu .mdi {
|
||||
width: 18px;
|
||||
}
|
||||
.dropdown-menu .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: -8px;
|
||||
}
|
||||
.nav .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
}
|
||||
.navbar .navbar-toggle .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
color: #FFF;
|
||||
}
|
||||
.breadcrumb .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.alert .mdi::before {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.input-group-addon .mdi::before {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
.navbar-brand .mdi::before {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.list-group-item .mdi::before {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
left: -3px
|
||||
}
|
||||
.mdi-sm::before {
|
||||
font-size: 1rem;
|
||||
line-height: unset;
|
||||
}
|
||||
/* Layout styling */
|
||||
.jjj-app {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.jjj-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.jjj-content {
|
||||
flex-grow: 2;
|
||||
}
|
||||
/* Menu styling */
|
||||
.jjj-full-menu,
|
||||
.jjj-mobile-menu {
|
||||
background-image: linear-gradient(180deg, darkgreen 0%, green 70%);
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.jjj-full-menu {
|
||||
min-height: 100vh;
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: none;
|
||||
}
|
||||
.jjj-full-menu .home-link {
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
background-color: rgba(0, 0, 0, .4);
|
||||
margin: -1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.jjj-full-menu a:link,
|
||||
.jjj-full-menu a:visited {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
#jjjMenu {
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.jjj-full-menu {
|
||||
display: unset;
|
||||
}
|
||||
.jjj-mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
.navbar-expand-md .navbar-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.jjj-nav a:link,
|
||||
.jjj-nav a:visited {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
nav.jjj-nav > a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: .25rem;
|
||||
padding: .5rem;
|
||||
margin: .5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
nav.jjj-nav > a > i {
|
||||
vertical-align: top;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
nav.jjj-nav > a > i.mdi::before {
|
||||
line-height: 24px;
|
||||
}
|
||||
nav.jjj-nav > a.jjj-current-page {
|
||||
background-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
nav.jjj-nav > a:hover {
|
||||
background-color: rgba(255, 255, 255, .5);
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav.jjj-nav > div.separator {
|
||||
border-bottom: solid 1px rgba(255, 255, 255, .75);
|
||||
height: 1px;
|
||||
}
|
||||
/* Title bar styling */
|
||||
.jjj-main .navbar-dark {
|
||||
background-image: linear-gradient(0deg, green 0%, darkgreen 70%);
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.jjj-main .navbar-dark button {
|
||||
padding: 0;
|
||||
}
|
||||
.jjj-main .navbar-dark .navbar-text {
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.jjj-main .navbar-light .navbar-text {
|
||||
font-style: italic;
|
||||
padding: 0 1rem 0 0;
|
||||
}
|
||||
/* Audio Clip styling */
|
||||
.jjj-audio-clip audio {
|
||||
display: none;
|
||||
}
|
||||
span.jjj-audio-clip {
|
||||
border-bottom: dotted 1px lightgray;
|
||||
}
|
||||
span.jjj-audio-clip:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Markdown Editor styling */
|
||||
.jjj-not-shown {
|
||||
display: none;
|
||||
}
|
||||
.jjj-shown {
|
||||
display: inherit;
|
||||
}
|
||||
.jjj-markdown-editor {
|
||||
width: 100%;
|
||||
/* When wrapping this with Bootstrap's floating label, it shrinks the input down to what a normal one-line input
|
||||
would be; this overrides that for this textarea specifically */
|
||||
height: inherit !important;
|
||||
}
|
||||
.jjj-markdown-preview {
|
||||
border: solid 1px lightgray;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
/* Collapse Panel styling */
|
||||
a[data-bs-toggle] {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
a[data-bs-toggle]:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Footer styling */
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
footer p {
|
||||
padding-top: 2rem;
|
||||
padding-right: .5rem;
|
||||
font-style: italic;
|
||||
font-size: .8rem;
|
||||
}
|
||||
279
src/JobsJobsJobs/Citizens/Data.fs
Normal file
279
src/JobsJobsJobs/Citizens/Data.fs
Normal file
@@ -0,0 +1,279 @@
|
||||
module JobsJobsJobs.Citizens.Data
|
||||
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open NodaTime
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// The last time a token purge check was run
|
||||
let mutable private lastPurge = Instant.MinValue
|
||||
|
||||
/// Lock access to the above
|
||||
let private locker = obj ()
|
||||
|
||||
/// Delete a citizen by their ID using the given connection properties
|
||||
let private doDeleteById citizenId connProps = backgroundTask {
|
||||
let! _ =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Delete a citizen by their ID
|
||||
let deleteById citizenId =
|
||||
doDeleteById citizenId (dataSource ())
|
||||
|
||||
/// Save a citizen
|
||||
let private saveCitizen (citizen : Citizen) connProps =
|
||||
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen)
|
||||
|
||||
/// Save security information for a citizen
|
||||
let private saveSecurity (security : SecurityInfo) connProps =
|
||||
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security)
|
||||
|
||||
/// Purge expired tokens
|
||||
let private purgeExpiredTokens now = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
let! info =
|
||||
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
|
||||
if expired.TokenUsage.Value = "confirm" then
|
||||
// Unconfirmed account; delete the entire thing
|
||||
do! doDeleteById expired.Id connProps
|
||||
else
|
||||
// Some other use; just clear the token
|
||||
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps
|
||||
}
|
||||
|
||||
/// Check for tokens to purge if it's been more than 10 minutes since we last checked
|
||||
let private checkForPurge skipCheck =
|
||||
lock locker (fun () -> backgroundTask {
|
||||
let now = SystemClock.Instance.GetCurrentInstant ()
|
||||
if skipCheck || (now - lastPurge).TotalMinutes >= 10 then
|
||||
do! purgeExpiredTokens now
|
||||
lastPurge <- now
|
||||
})
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById citizenId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
|
||||
| Some c when not c.IsLegacy -> return Some c
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Save a citizen
|
||||
let save citizen =
|
||||
saveCitizen citizen (dataSource ())
|
||||
|
||||
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
|
||||
let register citizen (security : SecurityInfo) = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
do! saveCitizen citizen connProps
|
||||
do! saveSecurity security connProps
|
||||
do! txn.CommitAsync ()
|
||||
return true
|
||||
with
|
||||
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
|
||||
do! txn.RollbackAsync ()
|
||||
return false
|
||||
}
|
||||
|
||||
/// Try to find the security information matching a confirmation token
|
||||
let private tryConfirmToken token connProps = backgroundTask {
|
||||
let! tryInfo =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.SecurityInfo}
|
||||
WHERE data ->> 'token' = @token
|
||||
AND data ->> 'tokenUsage' = 'confirm'"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead tryInfo
|
||||
}
|
||||
|
||||
/// Confirm a citizen's account
|
||||
let confirmAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
|
||||
connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
|
||||
let denyAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! doDeleteById info.Id connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Attempt a user log on
|
||||
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string)
|
||||
now = backgroundTask {
|
||||
do! checkForPurge false
|
||||
let connProps = dataSource ()
|
||||
let! tryCitizen =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.Citizen}
|
||||
WHERE data ->> 'email' = @email
|
||||
AND data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
match List.tryHead tryCitizen with
|
||||
| Some citizen ->
|
||||
let citizenId = CitizenId.toString citizen.Id
|
||||
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
|
||||
let! info = backgroundTask {
|
||||
match tryInfo with
|
||||
| Some it -> return it
|
||||
| None ->
|
||||
let it = { SecurityInfo.empty with Id = citizen.Id }
|
||||
do! saveSecurity it connProps
|
||||
return it
|
||||
}
|
||||
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
|
||||
else
|
||||
match pwVerify citizen password with
|
||||
| Some rehash ->
|
||||
let hash = if rehash then pwHash citizen password else citizen.PasswordHash
|
||||
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps
|
||||
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps
|
||||
return Ok { citizen with LastSeenOn = now }
|
||||
| None ->
|
||||
let locked = info.FailedLogOnAttempts >= 4
|
||||
do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|
||||
|> saveSecurity <| connProps
|
||||
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
|
||||
| None -> return Error "Log on unsuccessful"
|
||||
}
|
||||
|
||||
/// Try to retrieve a citizen and their security information by their e-mail address
|
||||
let tryByEmailWithSecurity email = backgroundTask {
|
||||
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row)
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*, s.data AS sec_data
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
|
||||
WHERE c.data ->> 'email' = @email"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toCitizenSecurityPair
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Save an updated security information document
|
||||
let saveSecurityInfo security = backgroundTask {
|
||||
do! saveSecurity security (dataSource ())
|
||||
}
|
||||
|
||||
/// Try to retrieve security information by the given token
|
||||
let trySecurityByToken token = backgroundTask {
|
||||
do! checkForPurge false
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'token' = @token"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
/// Get all legacy citizens
|
||||
let legacy () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Citizen} WHERE data ->> 'isLegacy' = 'true' ORDER BY data ->> 'firstName'"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
/// Get all current citizens with verified accounts but without a profile
|
||||
let current () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} si ON si.id = c.id
|
||||
WHERE c.data ->> 'isLegacy' = 'false'
|
||||
AND si.data ->> 'accountLocked' = 'false'
|
||||
AND NOT EXISTS (SELECT 1 FROM {Table.Profile} p WHERE p.id = c.id)"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
let migrateLegacy currentId legacyId = backgroundTask {
|
||||
let oldId = CitizenId.toString legacyId
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
// Add legacy data to current user
|
||||
let! profiles =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Profile} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Profile>
|
||||
match List.tryHead profiles with
|
||||
| Some profile ->
|
||||
do! saveDocument
|
||||
Table.Profile (CitizenId.toString currentId) (Sql.existingConnection conn)
|
||||
(mkDoc { profile with Id = currentId; IsLegacy = false })
|
||||
| None -> ()
|
||||
let! listings =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Listing>
|
||||
for listing in listings do
|
||||
let newListing = { listing with Id = ListingId.create (); CitizenId = currentId; IsLegacy = false }
|
||||
do! saveDocument
|
||||
Table.Listing (ListingId.toString newListing.Id) (Sql.existingConnection conn) (mkDoc newListing)
|
||||
let! successes =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Success>
|
||||
for success in successes do
|
||||
let newSuccess = { success with Id = SuccessId.create (); CitizenId = currentId }
|
||||
do! saveDocument
|
||||
Table.Success (SuccessId.toString newSuccess.Id) (Sql.existingConnection conn) (mkDoc newSuccess)
|
||||
// Delete legacy data
|
||||
let! _ =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
do! txn.CommitAsync ()
|
||||
return Ok ""
|
||||
with :? Npgsql.PostgresException as ex ->
|
||||
do! txn.RollbackAsync ()
|
||||
return Error ex.MessageText
|
||||
}
|
||||
164
src/JobsJobsJobs/Citizens/Domain.fs
Normal file
164
src/JobsJobsJobs/Citizens/Domain.fs
Normal file
@@ -0,0 +1,164 @@
|
||||
module JobsJobsJobs.Citizens.Domain
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// The data to add or update an other contact
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type OtherContactForm =
|
||||
{ /// The type of the contact
|
||||
ContactType : string
|
||||
|
||||
/// The name of the contact
|
||||
Name : string
|
||||
|
||||
/// The value of the contact (URL, e-mail address, phone, etc.)
|
||||
Value : string
|
||||
|
||||
/// Whether this contact is displayed for public employment profiles and job listings
|
||||
IsPublic : bool
|
||||
}
|
||||
|
||||
/// Support functions for the contact form
|
||||
module OtherContactForm =
|
||||
|
||||
/// Create a contact form from a contact
|
||||
let fromContact (contact : OtherContact) =
|
||||
{ ContactType = ContactType.toString contact.ContactType
|
||||
Name = defaultArg contact.Name ""
|
||||
Value = contact.Value
|
||||
IsPublic = contact.IsPublic
|
||||
}
|
||||
|
||||
|
||||
/// The data available to update an account profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type AccountProfileForm =
|
||||
{ /// The first name of the citizen
|
||||
FirstName : string
|
||||
|
||||
/// The last name of the citizen
|
||||
LastName : string
|
||||
|
||||
/// The display name for the citizen
|
||||
DisplayName : string
|
||||
|
||||
/// The citizen's new password
|
||||
NewPassword : string
|
||||
|
||||
/// Confirmation of the citizen's new password
|
||||
NewPasswordConfirm : string
|
||||
|
||||
/// The contacts for this profile
|
||||
Contacts : OtherContactForm array
|
||||
}
|
||||
|
||||
/// Support functions for the account profile form
|
||||
module AccountProfileForm =
|
||||
|
||||
/// Create an account profile form from a citizen
|
||||
let fromCitizen (citizen : Citizen) =
|
||||
{ FirstName = citizen.FirstName
|
||||
LastName = citizen.LastName
|
||||
DisplayName = defaultArg citizen.DisplayName ""
|
||||
NewPassword = ""
|
||||
NewPasswordConfirm = ""
|
||||
Contacts = citizen.OtherContacts |> List.map OtherContactForm.fromContact |> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// Form for the forgot / reset password page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ForgotPasswordForm =
|
||||
{ /// The e-mail address for the account wishing to reset their password
|
||||
Email : string
|
||||
}
|
||||
|
||||
|
||||
/// Form for the log on page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnForm =
|
||||
{ /// A message regarding an error encountered during a log on attempt
|
||||
ErrorMessage : string option
|
||||
|
||||
/// The e-mail address for the user attempting to log on
|
||||
Email : string
|
||||
|
||||
/// The password of the user attempting to log on
|
||||
Password : string
|
||||
|
||||
/// The URL where the user should be redirected after logging on
|
||||
ReturnTo : string option
|
||||
}
|
||||
|
||||
|
||||
/// Form for the registration page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type RegisterForm =
|
||||
{ /// The user's first name
|
||||
FirstName : string
|
||||
|
||||
/// The user's last name
|
||||
LastName : string
|
||||
|
||||
/// The user's display name
|
||||
DisplayName : string option
|
||||
|
||||
/// The user's e-mail address
|
||||
Email : string
|
||||
|
||||
/// The user's desired password
|
||||
Password : string
|
||||
|
||||
/// The index of the first question asked
|
||||
Question1Index : int
|
||||
|
||||
/// The answer for the first question asked
|
||||
Question1Answer : string
|
||||
|
||||
/// The index of the second question asked
|
||||
Question2Index : int
|
||||
|
||||
/// The answer for the second question asked
|
||||
Question2Answer : string
|
||||
}
|
||||
|
||||
/// Support for the registration page view model
|
||||
module RegisterForm =
|
||||
|
||||
/// An empty view model
|
||||
let empty =
|
||||
{ FirstName = ""
|
||||
LastName = ""
|
||||
DisplayName = None
|
||||
Email = ""
|
||||
Password = ""
|
||||
Question1Index = 0
|
||||
Question1Answer = ""
|
||||
Question2Index = 0
|
||||
Question2Answer = ""
|
||||
}
|
||||
|
||||
|
||||
/// The form for a user resetting their password
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ResetPasswordForm =
|
||||
{ /// The ID of the citizen whose password is being reset
|
||||
Id : string
|
||||
|
||||
/// The verification token for the password reset
|
||||
Token : string
|
||||
|
||||
/// The new password for the account
|
||||
Password : string
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~ //
|
||||
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LegacyMigrationForm =
|
||||
{ /// The ID of the current citizen
|
||||
Id : string
|
||||
|
||||
/// The ID of the legacy citizen to be migrated
|
||||
LegacyId : string
|
||||
}
|
||||
383
src/JobsJobsJobs/Citizens/Handlers.fs
Normal file
383
src/JobsJobsJobs/Citizens/Handlers.fs
Normal file
@@ -0,0 +1,383 @@
|
||||
module JobsJobsJobs.Citizens.Handlers
|
||||
|
||||
open System
|
||||
open System.Security.Claims
|
||||
open Giraffe
|
||||
open JobsJobsJobs
|
||||
open JobsJobsJobs.Citizens.Domain
|
||||
open JobsJobsJobs.Common.Handlers
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.Extensions.Logging
|
||||
open NodaTime
|
||||
|
||||
/// Authorization functions
|
||||
module private Auth =
|
||||
|
||||
open System.Text
|
||||
|
||||
/// Create a confirmation or password reset token for a user
|
||||
let createToken (citizen : Citizen) =
|
||||
Convert.ToBase64String (Guid.NewGuid().ToByteArray () |> Array.append (Encoding.UTF8.GetBytes citizen.Email))
|
||||
|
||||
/// The challenge questions and answers from the configuration
|
||||
let mutable private challenges : (string * string)[] option = None
|
||||
|
||||
/// The challenge questions and answers
|
||||
let questions ctx =
|
||||
match challenges with
|
||||
| Some it -> it
|
||||
| None ->
|
||||
let qs = (config ctx).GetSection "ChallengeQuestions"
|
||||
let qAndA =
|
||||
seq {
|
||||
for idx in 0..4 do
|
||||
let section = qs.GetSection(string idx)
|
||||
yield section["Question"], (section["Answer"].ToLowerInvariant ())
|
||||
}
|
||||
|> Array.ofSeq
|
||||
challenges <- Some qAndA
|
||||
qAndA
|
||||
|
||||
/// Password hashing and verification
|
||||
module Passwords =
|
||||
|
||||
open Microsoft.AspNetCore.Identity
|
||||
|
||||
/// The password hasher to use for the application
|
||||
let private hasher = PasswordHasher<Citizen> ()
|
||||
|
||||
/// Hash a password for a user
|
||||
let hash citizen password =
|
||||
hasher.HashPassword (citizen, password)
|
||||
|
||||
/// Verify a password (returns true if the password needs to be rehashed)
|
||||
let verify citizen password =
|
||||
match hasher.VerifyHashedPassword (citizen, citizen.PasswordHash, password) with
|
||||
| PasswordVerificationResult.Success -> Some false
|
||||
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
|
||||
| _ -> None
|
||||
|
||||
/// Require an administrative user (used for legacy migration endpoints)
|
||||
let requireAdmin : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let adminUser = (config ctx)["AdminUser"]
|
||||
if adminUser = defaultArg (tryUser ctx) "" then return! next ctx
|
||||
else return! Error.notAuthorized next ctx
|
||||
}
|
||||
|
||||
|
||||
// GET: /citizen/account
|
||||
let account : HttpHandler = fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some citizen ->
|
||||
return!
|
||||
Views.account (AccountProfileForm.fromCitizen citizen) (isHtmx ctx) (csrf ctx)
|
||||
|> render "Account Profile" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/cancel-reset/[token]
|
||||
let cancelReset token : HttpHandler = fun next ctx -> task {
|
||||
let! wasCanceled = task {
|
||||
match! Data.trySecurityByToken token with
|
||||
| Some security ->
|
||||
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
return! Views.resetCanceled wasCanceled |> render "Password Reset Cancellation" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/confirm/[token]
|
||||
let confirm token : HttpHandler = fun next ctx -> task {
|
||||
let! isConfirmed = Data.confirmAccount token
|
||||
return! Views.confirmAccount isConfirmed |> render "Account Confirmation" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/dashboard
|
||||
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! citizen = Data.findById citizenId
|
||||
let! profile = Profiles.Data.findById citizenId
|
||||
let! prfCount = Profiles.Data.count ()
|
||||
return! Views.dashboard citizen.Value profile prfCount (timeZone ctx) |> render "Dashboard" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/delete
|
||||
let delete : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
do! Data.deleteById (currentCitizenId ctx)
|
||||
do! ctx.SignOutAsync ()
|
||||
return! render "Account Deleted Successfully" next ctx Views.deleted
|
||||
}
|
||||
|
||||
// GET: /citizen/deny/[token]
|
||||
let deny token : HttpHandler = fun next ctx -> task {
|
||||
let! wasDeleted = Data.denyAccount token
|
||||
return! Views.denyAccount wasDeleted |> render "Account Deletion" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/forgot-password
|
||||
let forgotPassword : HttpHandler = fun next ctx ->
|
||||
Views.forgotPassword (csrf ctx) |> render "Forgot Password" next ctx
|
||||
|
||||
// POST: /citizen/forgot-password
|
||||
let doForgotPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<ForgotPasswordForm> ()
|
||||
match! Data.tryByEmailWithSecurity form.Email with
|
||||
| Some (citizen, security) ->
|
||||
let withToken =
|
||||
{ security with
|
||||
Token = Some (Auth.createToken citizen)
|
||||
TokenUsage = Some "reset"
|
||||
TokenExpires = Some (now ctx + (Duration.FromDays 3))
|
||||
}
|
||||
do! Data.saveSecurityInfo withToken
|
||||
let! emailResponse = Email.sendPasswordReset citizen withToken
|
||||
let logFac = logger ctx
|
||||
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
|
||||
log.LogInformation $"Password reset e-mail for {citizen.Email} received {emailResponse}"
|
||||
| None -> ()
|
||||
return! Views.forgotPasswordSent form |> render "Reset Request Processed" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/log-off
|
||||
let logOff : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
do! addSuccess "Log off successful" ctx
|
||||
return! redirectToGet "/" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/log-on
|
||||
let logOn : HttpHandler = fun next ctx ->
|
||||
let returnTo =
|
||||
if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
|
||||
Views.logOn { ErrorMessage = None; Email = ""; Password = ""; ReturnTo = returnTo } (csrf ctx)
|
||||
|> render "Log On" next ctx
|
||||
|
||||
|
||||
// POST: /citizen/log-on
|
||||
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<LogOnForm> ()
|
||||
match! Data.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
|
||||
| Ok citizen ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id)
|
||||
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
do! addSuccess "Log on successful" ctx
|
||||
return! redirectToGet (defaultArg form.ReturnTo "/citizen/dashboard") next ctx
|
||||
| Error msg ->
|
||||
do! addError msg ctx
|
||||
return! Views.logOn { form with Password = "" } (csrf ctx) |> render "Log On" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/register
|
||||
let register next ctx =
|
||||
// Get two different indexes for NA-knowledge challenge questions
|
||||
let q1Index = System.Random.Shared.Next(0, 5)
|
||||
let mutable q2Index = System.Random.Shared.Next(0, 5)
|
||||
while q1Index = q2Index do
|
||||
q2Index <- System.Random.Shared.Next(0, 5)
|
||||
let qAndA = Auth.questions ctx
|
||||
Views.register (fst qAndA[q1Index]) (fst qAndA[q2Index])
|
||||
{ RegisterForm.empty with Question1Index = q1Index; Question2Index = q2Index } (isHtmx ctx) (csrf ctx)
|
||||
|> render "Register" next ctx
|
||||
|
||||
// POST: /citizen/register
|
||||
#nowarn "3511"
|
||||
let doRegistration : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<RegisterForm> ()
|
||||
let qAndA = Auth.questions ctx
|
||||
let mutable badForm = false
|
||||
let errors = [
|
||||
if form.FirstName.Length < 1 then "First name is required"
|
||||
if form.LastName.Length < 1 then "Last name is required"
|
||||
if form.Email.Length < 1 then "E-mail address is required"
|
||||
if form.Password.Length < 8 then "Password is too short"
|
||||
if form.Question1Index < 0 || form.Question1Index > 4
|
||||
|| form.Question2Index < 0 || form.Question2Index > 4
|
||||
|| form.Question1Index = form.Question2Index then
|
||||
badForm <- true
|
||||
else if (snd qAndA[form.Question1Index]) <> (form.Question1Answer.Trim().ToLowerInvariant ())
|
||||
|| (snd qAndA[form.Question2Index]) <> (form.Question2Answer.Trim().ToLowerInvariant ()) then
|
||||
"Question answers are incorrect"
|
||||
]
|
||||
let refreshPage () =
|
||||
Views.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) { form with Password = "" }
|
||||
(isHtmx ctx) (csrf ctx)
|
||||
|> renderHandler "Register"
|
||||
|
||||
if badForm then
|
||||
do! addError "The form posted was invalid; please complete it again" ctx
|
||||
return! register next ctx
|
||||
else if List.isEmpty errors then
|
||||
let now = now ctx
|
||||
let noPass =
|
||||
{ Citizen.empty with
|
||||
Id = CitizenId.create ()
|
||||
Email = form.Email
|
||||
FirstName = form.FirstName
|
||||
LastName = form.LastName
|
||||
DisplayName = noneIfBlank form.DisplayName
|
||||
JoinedOn = now
|
||||
LastSeenOn = now
|
||||
}
|
||||
let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password }
|
||||
let security =
|
||||
{ SecurityInfo.empty with
|
||||
Id = citizen.Id
|
||||
AccountLocked = true
|
||||
Token = Some (Auth.createToken citizen)
|
||||
TokenUsage = Some "confirm"
|
||||
TokenExpires = Some (now + (Duration.FromDays 3))
|
||||
}
|
||||
let! success = Data.register citizen security
|
||||
if success then
|
||||
let! emailResponse = Email.sendAccountConfirmation citizen security
|
||||
let logFac = logger ctx
|
||||
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
|
||||
log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
|
||||
return! Views.registered |> render "Registration Successful" next ctx
|
||||
else
|
||||
do! addError "There is already an account registered to the e-mail address provided" ctx
|
||||
return! refreshPage () next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! refreshPage () next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/reset-password/[token]
|
||||
let resetPassword token : HttpHandler = fun next ctx -> task {
|
||||
match! Data.trySecurityByToken token with
|
||||
| Some security ->
|
||||
return!
|
||||
Views.resetPassword { Id = CitizenId.toString security.Id; Token = token; Password = "" } (isHtmx ctx)
|
||||
(csrf ctx)
|
||||
|> render "Reset Password" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/reset-password
|
||||
let doResetPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<ResetPasswordForm> ()
|
||||
let errors = [
|
||||
if form.Id = "" then "Request invalid; please return to the link in your e-mail and try again"
|
||||
if form.Token = "" then "Request invalid; please return to the link in your e-mail and try again"
|
||||
if form.Password.Length < 8 then "Password too short"
|
||||
]
|
||||
if List.isEmpty errors then
|
||||
match! Data.trySecurityByToken form.Token with
|
||||
| Some security when security.Id = CitizenId.ofString form.Id ->
|
||||
match! Data.findById security.Id with
|
||||
| Some citizen ->
|
||||
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
|
||||
do! Data.save { citizen with PasswordHash = Auth.Passwords.hash citizen form.Password }
|
||||
do! addSuccess "Password reset successfully; you may log on with your new credentials" ctx
|
||||
return! redirectToGet "/citizen/log-on" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| Some _
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! Views.resetPassword form (isHtmx ctx) (csrf ctx) |> render "Reset Password" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/save-account
|
||||
let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
|
||||
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
|
||||
let errors = [
|
||||
if form.FirstName = "" then "First Name is required"
|
||||
if form.LastName = "" then "Last Name is required"
|
||||
if form.NewPassword <> form.NewPassword then "New passwords do not match"
|
||||
if form.Contacts |> Array.exists (fun c -> c.ContactType = "") then "All Contact Types are required"
|
||||
if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
|
||||
]
|
||||
if List.isEmpty errors then
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some citizen ->
|
||||
let password =
|
||||
if form.NewPassword = "" then citizen.PasswordHash
|
||||
else Auth.Passwords.hash citizen form.NewPassword
|
||||
do! Data.save
|
||||
{ citizen with
|
||||
FirstName = form.FirstName
|
||||
LastName = form.LastName
|
||||
DisplayName = noneIfEmpty form.DisplayName
|
||||
PasswordHash = password
|
||||
OtherContacts = form.Contacts
|
||||
|> Array.map (fun c ->
|
||||
{ OtherContact.Name = noneIfEmpty c.Name
|
||||
ContactType = ContactType.parse c.ContactType
|
||||
Value = c.Value
|
||||
IsPublic = c.IsPublic
|
||||
})
|
||||
|> List.ofArray
|
||||
}
|
||||
let extraMsg = if form.NewPassword = "" then "" else " and password changed"
|
||||
do! addSuccess $"Account profile updated{extraMsg} successfully" ctx
|
||||
return! redirectToGet "/citizen/account" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! Views.account form (isHtmx ctx) (csrf ctx) |> render "Account Profile" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/so-long
|
||||
let soLong : HttpHandler = requireUser >=> fun next ctx ->
|
||||
Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
// GET: /citizen/legacy
|
||||
let legacy : HttpHandler = Auth.requireAdmin >=> fun next ctx -> task {
|
||||
let! currentUsers = Data.current ()
|
||||
let! legacyUsers = Data.legacy ()
|
||||
return! Views.legacy currentUsers legacyUsers (csrf ctx) |> render "Migrate Legacy Account" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/legacy/migrate
|
||||
let migrateLegacy : HttpHandler = Auth.requireAdmin >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<LegacyMigrationForm> ()
|
||||
let currentId = CitizenId.ofString form.Id
|
||||
let legacyId = CitizenId.ofString form.LegacyId
|
||||
match! Data.migrateLegacy currentId legacyId with
|
||||
| Ok _ -> do! addSuccess "Migration successful" ctx
|
||||
| Error err -> do! addError err ctx
|
||||
return! redirectToGet "/citizen/legacy" next ctx
|
||||
}
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All endpoints for this feature
|
||||
let endpoints =
|
||||
subRoute "/citizen" [
|
||||
GET_HEAD [
|
||||
route "/account" account
|
||||
routef "/cancel-reset/%s" cancelReset
|
||||
routef "/confirm/%s" confirm
|
||||
route "/dashboard" dashboard
|
||||
routef "/deny/%s" deny
|
||||
route "/forgot-password" forgotPassword
|
||||
route "/log-off" logOff
|
||||
route "/log-on" logOn
|
||||
route "/register" register
|
||||
routef "/reset-password/%s" resetPassword
|
||||
route "/so-long" soLong
|
||||
route "/legacy" legacy
|
||||
]
|
||||
POST [
|
||||
route "/delete" delete
|
||||
route "/forgot-password" doForgotPassword
|
||||
route "/log-on" doLogOn
|
||||
route "/register" doRegistration
|
||||
route "/reset-password" doResetPassword
|
||||
route "/save-account" saveAccount
|
||||
route "/legacy/migrate" migrateLegacy
|
||||
]
|
||||
]
|
||||
19
src/JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj
Normal file
19
src/JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
446
src/JobsJobsJobs/Citizens/Views.fs
Normal file
446
src/JobsJobsJobs/Citizens/Views.fs
Normal file
@@ -0,0 +1,446 @@
|
||||
/// Views for URLs beginning with /citizen
|
||||
module JobsJobsJobs.Citizens.Views
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.Citizens.Domain
|
||||
open JobsJobsJobs.Common.Views
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// The form to add or edit a means of contact
|
||||
let contactEdit (contacts : OtherContactForm array) =
|
||||
let mapToInputs (idx : int) (contact : OtherContactForm) =
|
||||
div [ _id $"contactRow{idx}"; _class "row pb-3" ] [
|
||||
div [ _class "col-2 col-md-1" ] [
|
||||
button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete"
|
||||
_onclick $"jjj.citizen.removeContact({idx})" ] [ txt " − " ]
|
||||
]
|
||||
div [ _class "col-10 col-md-4 col-xl-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _id $"contactType{idx}"; _name $"Contacts[{idx}].ContactType"; _class "form-control"
|
||||
_value contact.ContactType; _placeholder "Type"; _required ] [
|
||||
let optionFor value label =
|
||||
let typ = ContactType.toString value
|
||||
option [ _value typ; if contact.ContactType = typ then _selected ] [ txt label ]
|
||||
optionFor Website "Website"
|
||||
optionFor Email "E-mail Address"
|
||||
optionFor Phone "Phone Number"
|
||||
]
|
||||
label [ _class "jjj-required"; _for $"contactType{idx}" ] [ txt "Type" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control"
|
||||
_maxlength "1000"; _value contact.Name; _placeholder "Name" ]
|
||||
label [ _class "jjj-label"; _for $"contactName{idx}" ] [ txt "Name" ]
|
||||
]
|
||||
if idx < 1 then
|
||||
div [ _class "form-text" ] [ txt "Optional; will link sites and e-mail, qualify phone numbers" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value"
|
||||
_class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact"
|
||||
_required ]
|
||||
label [ _class "jjj-required"; _for "contactValue{idx}" ] [ txt "Contact" ]
|
||||
]
|
||||
if idx < 1 then div [ _class "form-text"] [ txt "The URL, e-mail address, or phone number" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [
|
||||
div [ _class "form-check mt-3" ] [
|
||||
input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic";
|
||||
_class "form-check-input"; _value "true"; if contact.IsPublic then _checked ]
|
||||
label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ txt "Public" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
template [ _id "newContact" ] [
|
||||
mapToInputs -1 { ContactType = "Website"; Name = ""; Value = ""; IsPublic = false }
|
||||
]
|
||||
:: (contacts |> Array.mapi mapToInputs |> List.ofArray)
|
||||
|
||||
/// The account edit page
|
||||
let account (m : AccountProfileForm) isHtmx csrf =
|
||||
pageWithTitle "Account Profile" [
|
||||
p [] [
|
||||
txt "This information is visible to all fellow logged-on citizens. For publicly-visible employment "
|
||||
txt "profiles and job listings, the “Display Name” fields and any public contacts will be "
|
||||
txt "displayed."
|
||||
]
|
||||
form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false
|
||||
div [ _class "form-text" ] [ em [] [ txt "Optional; overrides first/last for display" ] ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false
|
||||
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password"
|
||||
false
|
||||
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [ _class "pb-2" ] [
|
||||
txt "Ways to Be Contacted "
|
||||
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
|
||||
_onclick "jjj.citizen.addContact()" ] [ txt "Add a Contact Method" ]
|
||||
]
|
||||
]
|
||||
yield! contactEdit m.Contacts
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
]
|
||||
hr []
|
||||
p [ _class "text-muted fst-italic" ] [
|
||||
txt "(If you want to delete your profile, or your entire account, "
|
||||
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; txt ".)"
|
||||
]
|
||||
jsOnLoad $"
|
||||
jjj.citizen.nextIndex = {m.Contacts.Length}
|
||||
jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)" isHtmx
|
||||
]
|
||||
|
||||
|
||||
/// The account confirmation page
|
||||
let confirmAccount isConfirmed =
|
||||
pageWithTitle "Account Confirmation" [
|
||||
p [] [
|
||||
if isConfirmed then
|
||||
txt "Your account was confirmed successfully! You may "
|
||||
a [ _href "/citizen/log-on" ] [ rawText "log on here" ]; txt "."
|
||||
else
|
||||
txt "The confirmation token did not match any pending accounts. Confirmation tokens are only valid for "
|
||||
txt "3 days; if the token expired, you will need to re-register, which "
|
||||
a [ _href "/citizen/register" ] [ txt "you can do here" ]; txt "."
|
||||
]
|
||||
]
|
||||
|
||||
/// The citizen's dashboard page
|
||||
let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz =
|
||||
article [ _class "container" ] [
|
||||
h3 [ _class "pb-4" ] [ str $"ITM, {citizen.FirstName}!" ]
|
||||
div [ _class "row row-cols-1 row-cols-md-2" ] [
|
||||
div [ _class "col" ] [
|
||||
div [ _class "card h-100" ] [
|
||||
h5 [ _class "card-header" ] [ txt "Your Profile" ]
|
||||
div [ _class "card-body" ] [
|
||||
match profile with
|
||||
| Some prfl ->
|
||||
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||
str $"Last updated {fullDateTime prfl.LastUpdatedOn tz}"
|
||||
]
|
||||
p [ _class "card-text" ] [
|
||||
txt $"Your profile currently lists {List.length prfl.Skills} skill"
|
||||
txt (if List.length prfl.Skills <> 1 then "s" else ""); txt "."
|
||||
if prfl.IsSeekingEmployment then
|
||||
br []; br []
|
||||
txt "Your profile indicates that you are seeking employment. Once you find it, "
|
||||
a [ _href "/success-story/add" ] [ txt "tell your fellow citizens about it!" ]
|
||||
]
|
||||
| None ->
|
||||
p [ _class "card-text" ] [
|
||||
txt "You do not have an employment profile established; click below (or “Edit "
|
||||
txt "Profile” in the menu) to get started!"
|
||||
]
|
||||
]
|
||||
div [ _class "card-footer" ] [
|
||||
match profile with
|
||||
| Some _ ->
|
||||
a [ _href $"/profile/{CitizenId.toString citizen.Id}/view"
|
||||
_class "btn btn-outline-secondary" ] [ txt "View Profile" ]; txt " "
|
||||
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ txt "Edit Profile" ]
|
||||
| None ->
|
||||
a [ _href "/profile/edit"; _class "btn btn-primary" ] [ txt "Create Profile" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col" ] [
|
||||
div [ _class "card h-100" ] [
|
||||
h5 [ _class "card-header" ] [ txt "Other Citizens" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||
txt (if profileCount = 0L then "No" else $"{profileCount} Total")
|
||||
txt " Employment Profile"; txt (if profileCount <> 1 then "s" else "")
|
||||
]
|
||||
p [ _class "card-text" ] [
|
||||
if profileCount = 1 && Option.isSome profile then
|
||||
"It looks like, for now, it’s just you…"
|
||||
else if profileCount > 0 then "Take a look around and see if you can help them find work!"
|
||||
else "You can click below, but you will not find anything…"
|
||||
|> txt
|
||||
]
|
||||
]
|
||||
div [ _class "card-footer" ] [
|
||||
a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ txt "Search Profiles" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
emptyP
|
||||
p [] [
|
||||
txt "To see how this application works, check out “How It Works” in the sidebar (last updated "
|
||||
txt "August 29<sup>th</sup>, 2021)."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The account deletion success page
|
||||
let deleted =
|
||||
pageWithTitle "Account Deletion Success" [
|
||||
emptyP; p [] [ txt "Your account has been successfully deleted." ]
|
||||
emptyP; p [] [ txt "Thank you for participating, and thank you for your courage. #GitmoNation" ]
|
||||
]
|
||||
|
||||
|
||||
/// The profile or account deletion page
|
||||
let deletionOptions csrf =
|
||||
pageWithTitle "Account Deletion Options" [
|
||||
h4 [ _class "pb-3" ] [ txt "Option 1 – Delete Your Profile" ]
|
||||
p [] [
|
||||
txt "Utilizing this option will remove your current employment profile and skills. This will preserve any "
|
||||
txt "job listings you may have posted, or any success stories you may have written, and preserves this "
|
||||
txt "this application’s knowledge of you. This is what you want to use if you want to clear out your "
|
||||
txt "profile and start again (and remove the current one from others’ view)."
|
||||
]
|
||||
form [ _class "text-center"; _method "POST"; _action "/profile/delete" ] [
|
||||
antiForgery csrf
|
||||
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Profile" ]
|
||||
]
|
||||
hr []
|
||||
h4 [ _class "pb-3" ] [ txt "Option 2 – Delete Your Account" ]
|
||||
p [] [
|
||||
txt "This option will make it like you never visited this site. It will delete your profile, skills, job "
|
||||
txt "listings, success stories, and account. This is what you want to use if you want to disappear from "
|
||||
txt "this application."
|
||||
]
|
||||
form [ _class "text-center"; _method "POST"; _action "/citizen/delete" ] [
|
||||
antiForgery csrf
|
||||
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Entire Account" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The account denial page
|
||||
let denyAccount wasDeleted =
|
||||
pageWithTitle "Account Deletion" [
|
||||
p [] [
|
||||
if wasDeleted then txt "The account was deleted successfully; sorry for the trouble."
|
||||
else
|
||||
txt "The confirmation token did not match any pending accounts; if this was an inadvertently created "
|
||||
txt "account, it has likely already been deleted."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The forgot / reset password page
|
||||
let forgotPassword csrf =
|
||||
let m = { Email = "" }
|
||||
pageWithTitle "Forgot Password" [
|
||||
p [] [
|
||||
txt "Enter your e-mail address below; if it matches the e-mail address of an account, we will send a "
|
||||
txt "password reset link."
|
||||
]
|
||||
form [ _class "row g-3 pb-3"; _method "POST"; _action "/citizen/forgot-password" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-12 col-md-6 offset-md-3" ] [
|
||||
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "send-lock-outline" "Send Reset Link" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The page displayed after a forgotten / reset request has been processed
|
||||
let forgotPasswordSent (m : ForgotPasswordForm) =
|
||||
pageWithTitle "Reset Request Processed" [
|
||||
p [] [
|
||||
txt $"The reset link request has been processed. If the e-mail address {m.Email} matched an account, "
|
||||
txt "further instructions were sent to that address."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The log on page
|
||||
let logOn (m : LogOnForm) csrf =
|
||||
pageWithTitle "Log On" [
|
||||
match m.ErrorMessage with
|
||||
| Some msg ->
|
||||
p [ _class "pb-3 text-center" ] [
|
||||
span [ _class "text-danger" ] [ txt msg ]; br []
|
||||
if msg.IndexOf("ocked") > -1 then
|
||||
txt "If this is a new account, it must be confirmed before it can be used; otherwise, you need to "
|
||||
a [ _href "/citizen/forgot-password" ] [ txt "request an unlock code" ]
|
||||
txt " before you may log on."
|
||||
]
|
||||
| None -> ()
|
||||
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
|
||||
antiForgery csrf
|
||||
match m.ReturnTo with
|
||||
| Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ]
|
||||
| None -> ()
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "password" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "login" "Log On" ]
|
||||
]
|
||||
p [ _class "text-center" ] [
|
||||
txt "Need an account? "; a [ _href "/citizen/register" ] [ txt "Register for one!" ]
|
||||
]
|
||||
p [ _class "text-center" ] [
|
||||
txt "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ txt "Request a reset." ]
|
||||
]
|
||||
]
|
||||
|
||||
/// The registration page
|
||||
let register q1 q2 (m : RegisterForm) isHtmx csrf =
|
||||
pageWithTitle "Register" [
|
||||
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false
|
||||
div [ _class "form-text fst-italic" ] [ txt "Optional; overrides first/last for display" ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
p [ _class "mb-0 text-muted fst-italic" ] [
|
||||
txt "Before your account request is through, you must answer these questions two…"
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer q1 true
|
||||
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer q2 true
|
||||
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
|
||||
]
|
||||
]
|
||||
|
||||
/// The confirmation page for user registration
|
||||
let registered =
|
||||
pageWithTitle "Registration Successful" [
|
||||
p [] [
|
||||
txt "You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation "
|
||||
txt "link; it will be valid for the next 72 hours (3 days). Once you confirm your account, you will be "
|
||||
txt "able to log on using the e-mail address and password you provided."
|
||||
]
|
||||
p [] [
|
||||
txt "If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to "
|
||||
txt "register again."
|
||||
]
|
||||
p [] [
|
||||
txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance."
|
||||
]
|
||||
]
|
||||
|
||||
/// The confirmation page for canceling a reset request
|
||||
let resetCanceled wasCanceled =
|
||||
let pgTitle = if wasCanceled then "Password Reset Request Canceled" else "Reset Request Not Found"
|
||||
pageWithTitle pgTitle [
|
||||
p [] [
|
||||
if wasCanceled then txt "Your password reset request has been canceled."
|
||||
else txt "There was no active password reset request found; it may have already expired."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The password reset page
|
||||
let resetPassword (m : ResetPasswordForm) isHtmx csrf =
|
||||
pageWithTitle "Reset Password" [
|
||||
p [] [ txt "Enter your new password in the fields below" ]
|
||||
form [ _class "row g-3"; _method "POST"; _action "/citizen/reset-password" ] [
|
||||
antiForgery csrf
|
||||
input [ _type "hidden"; _name (nameof m.Id); _value m.Id ]
|
||||
input [ _type "hidden"; _name (nameof m.Token); _value m.Token ]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-2" ] [
|
||||
textBox [ _type "password"; _minlength "8"; _autofocus ] (nameof m.Password) "" "New Password" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm New Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "lock-reset" "Reset Password" ]
|
||||
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
|
||||
]
|
||||
]
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
let legacy (current : Citizen list) (legacy : Citizen list) csrf =
|
||||
form [ _class "container"; _hxPost "/citizen/legacy/migrate" ] [
|
||||
antiForgery csrf
|
||||
let canProcess = not (List.isEmpty current)
|
||||
div [ _class "row" ] [
|
||||
if canProcess then
|
||||
div [ _class "col-12 col-lg-6 col-xxl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _id "current"; _name "Id"; _class "form-control" ] [
|
||||
option [ _value "" ] [ txt "– Select –" ]
|
||||
yield!
|
||||
current
|
||||
|> List.sortBy Citizen.name
|
||||
|> List.map (fun it ->
|
||||
option [ _value (CitizenId.toString it.Id) ] [
|
||||
str (Citizen.name it); txt " ("; str it.Email; txt ")"
|
||||
])
|
||||
]
|
||||
label [ _for "current" ] [ txt "Current User" ]
|
||||
]
|
||||
]
|
||||
else p [] [ txt "There are no current accounts to which legacy accounts can be migrated" ]
|
||||
div [ _class "col-12 col-lg-6 offset-xxl-2"] [
|
||||
table [ _class "table table-sm table-hover" ] [
|
||||
thead [] [
|
||||
tr [] [
|
||||
th [ _scope "col" ] [ txt "Select" ]
|
||||
th [ _scope "col" ] [ txt "NAS Profile" ]
|
||||
]
|
||||
]
|
||||
legacy |> List.map (fun it ->
|
||||
let theId = CitizenId.toString it.Id
|
||||
tr [] [
|
||||
td [] [
|
||||
if canProcess then
|
||||
input [ _type "radio"; _id $"legacy_{theId}"; _name "LegacyId"; _value theId ]
|
||||
else txt " "
|
||||
]
|
||||
td [] [ label [ _for $"legacy_{theId}" ] [ str it.Email ] ]
|
||||
])
|
||||
|> tbody []
|
||||
]
|
||||
]
|
||||
]
|
||||
submitButton "content-save-outline" "Migrate Account"
|
||||
]
|
||||
|> List.singleton
|
||||
|> pageWithTitle "Migrate Legacy Account"
|
||||
235
src/JobsJobsJobs/Common/Cache.fs
Normal file
235
src/JobsJobsJobs/Common/Cache.fs
Normal file
@@ -0,0 +1,235 @@
|
||||
namespace JobsJobsJobs
|
||||
|
||||
open NodaTime
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Helper types and functions for the cache
|
||||
[<AutoOpen>]
|
||||
module private CacheHelpers =
|
||||
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
open Npgsql
|
||||
|
||||
/// The cache entry
|
||||
type Entry =
|
||||
{ /// The ID of the cache entry
|
||||
Id : string
|
||||
|
||||
/// The value to be cached
|
||||
Payload : byte[]
|
||||
|
||||
/// When this entry will expire
|
||||
ExpireAt : Instant
|
||||
|
||||
/// The duration by which the expiration should be pushed out when being refreshed
|
||||
SlidingExpiration : Duration option
|
||||
|
||||
/// The must-expire-by date/time for the cache entry
|
||||
AbsoluteExpiration : Instant option
|
||||
}
|
||||
|
||||
/// Run a task synchronously
|
||||
let sync<'T> (it : Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
|
||||
|
||||
/// Get the current instant
|
||||
let getNow () = SystemClock.Instance.GetCurrentInstant ()
|
||||
|
||||
/// Get the first result of the given query
|
||||
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
|
||||
let! results = query
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Create a parameter for a non-standard type
|
||||
let typedParam<'T> name (it : 'T) =
|
||||
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
|
||||
|
||||
/// Create a parameter for a possibly-missing non-standard type
|
||||
let optParam<'T> name (it : 'T option) =
|
||||
let p = NpgsqlParameter ($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
|
||||
p.ParameterName, Sql.parameter p
|
||||
|
||||
/// Create a parameter for the expire-at time
|
||||
let expireParam =
|
||||
typedParam "expireAt"
|
||||
|
||||
|
||||
open System.Threading
|
||||
open JobsJobsJobs.Common.Data
|
||||
open Microsoft.Extensions.Caching.Distributed
|
||||
|
||||
// getEntry isn't resumable
|
||||
#nowarn "3511"
|
||||
|
||||
/// A distributed cache implementation in PostgreSQL used to handle sessions for Jobs, Jobs, Jobs
|
||||
type DistributedCache () =
|
||||
|
||||
// ~~~ INITIALIZATION ~~~
|
||||
|
||||
do
|
||||
task {
|
||||
let dataSource = dataSource ()
|
||||
let! exists =
|
||||
dataSource
|
||||
|> Sql.query $"
|
||||
SELECT EXISTS
|
||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
||||
AS does_exist"
|
||||
|> Sql.executeRowAsync (fun row -> row.bool "does_exist")
|
||||
if not exists then
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query
|
||||
"CREATE TABLE session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
payload BYTEA NOT NULL,
|
||||
expire_at TIMESTAMPTZ NOT NULL,
|
||||
sliding_expiration INTERVAL,
|
||||
absolute_expiration TIMESTAMPTZ);
|
||||
CREATE INDEX idx_session_expiration ON session (expire_at)"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
} |> sync
|
||||
|
||||
// ~~~ SUPPORT FUNCTIONS ~~~
|
||||
|
||||
/// Get an entry, updating it for sliding expiration
|
||||
let getEntry key = backgroundTask {
|
||||
let dataSource = dataSource ()
|
||||
let idParam = "@id", Sql.string key
|
||||
let! tryEntry =
|
||||
dataSource
|
||||
|> Sql.query "SELECT * FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ idParam ]
|
||||
|> Sql.executeAsync (fun row ->
|
||||
{ Id = row.string "id"
|
||||
Payload = row.bytea "payload"
|
||||
ExpireAt = row.fieldValue<Instant> "expire_at"
|
||||
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
|
||||
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
||||
|> tryHead
|
||||
match tryEntry with
|
||||
| Some entry ->
|
||||
let now = getNow ()
|
||||
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
|
||||
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
|
||||
let needsRefresh, item =
|
||||
if entry.ExpireAt = absExp then false, entry
|
||||
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
|
||||
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
|
||||
true, { entry with ExpireAt = absExp }
|
||||
else true, { entry with ExpireAt = now.Plus slideExp }
|
||||
if needsRefresh then
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
||||
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
return if item.ExpireAt > now then Some entry else None
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// The last time expired entries were purged (runs every 30 minutes)
|
||||
let mutable lastPurge = Instant.MinValue
|
||||
|
||||
/// Purge expired entries every 30 minutes
|
||||
let purge () = backgroundTask {
|
||||
let now = getNow ()
|
||||
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE expire_at < @expireAt"
|
||||
|> Sql.parameters [ expireParam now ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
lastPurge <- now
|
||||
}
|
||||
|
||||
/// Remove a cache entry
|
||||
let removeEntry key = backgroundTask {
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string key ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Save an entry
|
||||
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
|
||||
let now = getNow ()
|
||||
let expireAt, slideExp, absExp =
|
||||
if opts.SlidingExpiration.HasValue then
|
||||
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
|
||||
now.Plus slide, Some slide, None
|
||||
elif opts.AbsoluteExpiration.HasValue then
|
||||
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
|
||||
exp, None, Some exp
|
||||
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
|
||||
let exp = now.Plus (Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
|
||||
exp, None, Some exp
|
||||
else
|
||||
// Default to 1 hour sliding expiration
|
||||
let slide = Duration.FromHours 1
|
||||
now.Plus slide, Some slide, None
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"INSERT INTO session (
|
||||
id, payload, expire_at, sliding_expiration, absolute_expiration
|
||||
) VALUES (
|
||||
@id, @payload, @expireAt, @slideExp, @absExp
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
SET payload = EXCLUDED.payload,
|
||||
expire_at = EXCLUDED.expire_at,
|
||||
sliding_expiration = EXCLUDED.sliding_expiration,
|
||||
absolute_expiration = EXCLUDED.absolute_expiration"
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string key
|
||||
"@payload", Sql.bytea payload
|
||||
expireParam expireAt
|
||||
optParam "slideExp" slideExp
|
||||
optParam "absExp" absExp ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
||||
|
||||
/// Retrieve the data for a cache entry
|
||||
let get key (_ : CancellationToken) = backgroundTask {
|
||||
match! getEntry key with
|
||||
| Some entry ->
|
||||
do! purge ()
|
||||
return entry.Payload
|
||||
| None -> return null
|
||||
}
|
||||
|
||||
/// Refresh an entry
|
||||
let refresh key (cancelToken : CancellationToken) = backgroundTask {
|
||||
let! _ = get key cancelToken
|
||||
()
|
||||
}
|
||||
|
||||
/// Remove an entry
|
||||
let remove key (_ : CancellationToken) = backgroundTask {
|
||||
do! removeEntry key
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
/// Set an entry
|
||||
let set key value options (_ : CancellationToken) = backgroundTask {
|
||||
do! saveEntry options key value
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
interface IDistributedCache with
|
||||
member _.Get key = get key CancellationToken.None |> sync
|
||||
member _.GetAsync (key, token) = get key token
|
||||
member _.Refresh key = refresh key CancellationToken.None |> sync
|
||||
member _.RefreshAsync (key, token) = refresh key token
|
||||
member _.Remove key = remove key CancellationToken.None |> sync
|
||||
member _.RemoveAsync (key, token) = remove key token
|
||||
member _.Set (key, value, options) = set key value options CancellationToken.None |> sync
|
||||
member _.SetAsync (key, value, options, token) = set key value options token
|
||||
200
src/JobsJobsJobs/Common/Data.fs
Normal file
200
src/JobsJobsJobs/Common/Data.fs
Normal file
@@ -0,0 +1,200 @@
|
||||
module JobsJobsJobs.Common.Data
|
||||
|
||||
/// Constants for tables used by Jobs, Jobs, Jobs
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
|
||||
/// Citizens
|
||||
[<Literal>]
|
||||
let Citizen = "jjj.citizen"
|
||||
|
||||
/// Continents
|
||||
[<Literal>]
|
||||
let Continent = "jjj.continent"
|
||||
|
||||
/// Job Listings
|
||||
[<Literal>]
|
||||
let Listing = "jjj.listing"
|
||||
|
||||
/// Employment Profiles
|
||||
[<Literal>]
|
||||
let Profile = "jjj.profile"
|
||||
|
||||
/// User Security Information
|
||||
[<Literal>]
|
||||
let SecurityInfo = "jjj.security_info"
|
||||
|
||||
/// Success Stories
|
||||
[<Literal>]
|
||||
let Success = "jjj.success"
|
||||
|
||||
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Connection management for the document store
|
||||
[<AutoOpen>]
|
||||
module DataConnection =
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Npgsql
|
||||
|
||||
/// The data source for the document store
|
||||
let mutable private theDataSource : NpgsqlDataSource option = None
|
||||
|
||||
/// Get the data source as the start of a SQL statement
|
||||
let dataSource () =
|
||||
match theDataSource with
|
||||
| Some ds -> Sql.fromDataSource ds
|
||||
| None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
|
||||
|
||||
/// Create tables
|
||||
let private createTables () = backgroundTask {
|
||||
let sql = [
|
||||
"CREATE SCHEMA IF NOT EXISTS jjj"
|
||||
// Tables
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Continent} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Listing} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Profile} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
text_search TSVECTOR NOT NULL,
|
||||
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
CONSTRAINT fk_security_info_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Success} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
// Key indexes
|
||||
$"CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))"
|
||||
// Profile text search index
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
|
||||
]
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
|
||||
()
|
||||
}
|
||||
|
||||
/// Create functions and triggers required to
|
||||
let createTriggers () = backgroundTask {
|
||||
let! functions =
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"SELECT p.proname
|
||||
FROM pg_catalog.pg_proc p
|
||||
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'jjj'"
|
||||
|> Sql.executeAsync (fun row -> row.string "proname")
|
||||
if not (functions |> List.contains "indexable_array_string") then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query """
|
||||
CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
|
||||
BEGIN
|
||||
RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text,
|
||||
'["', ''), '", "', ' '), '"]', ''), '[]', '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;"""
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
if not (functions |> List.contains "set_text_search") then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.text_search := to_tsvector('english',
|
||||
COALESCE(NEW.data ->> 'region', '') || ' '
|
||||
|| COALESCE(NEW.data ->> 'biography', '') || ' '
|
||||
|| COALESCE(NEW.data ->> 'experience', '') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.skills[*].description') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].employer') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].position') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].description'));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile}
|
||||
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Set up the data connection from the given configuration
|
||||
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL")
|
||||
let _ = builder.UseNodaTime ()
|
||||
theDataSource <- Some (builder.Build ())
|
||||
do! createTables ()
|
||||
do! createTriggers ()
|
||||
}
|
||||
|
||||
|
||||
open System.Text.Json
|
||||
open System.Threading.Tasks
|
||||
open JobsJobsJobs
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocumentFrom<'T> fieldName (row : RowReader) =
|
||||
JsonSerializer.Deserialize<'T> (row.string fieldName, Json.options)
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocument<'T> (row : RowReader) = toDocumentFrom<'T> "data" row
|
||||
|
||||
/// Get a document
|
||||
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
|
||||
let! doc =
|
||||
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|
||||
|> Sql.parameters [ "@id", Sql.string docId ]
|
||||
|> Sql.executeAsync toDocument
|
||||
return List.tryHead doc
|
||||
}
|
||||
|
||||
/// Serialize a document to JSON
|
||||
let mkDoc<'T> (doc : 'T) =
|
||||
JsonSerializer.Serialize<'T> (doc, Json.options)
|
||||
|
||||
/// Save a document
|
||||
let saveDocument table docId sqlProps doc = backgroundTask {
|
||||
let! _ =
|
||||
Sql.query
|
||||
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
|
||||
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
|
||||
sqlProps
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string docId
|
||||
"@data", Sql.jsonb doc ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Create a match-anywhere clause for a LIKE or ILIKE clause
|
||||
let like value =
|
||||
Sql.string $"%%%s{value}%%"
|
||||
|
||||
/// The JSON access operator ->> makes values text; this makes a parameter that will compare the properly
|
||||
let jsonBool value =
|
||||
Sql.string (if value then "true" else "false")
|
||||
|
||||
/// Get the SQL for a search WHERE clause
|
||||
let searchSql criteria =
|
||||
let sql = criteria |> List.map fst |> String.concat " AND "
|
||||
if sql = "" then "" else $"AND {sql}"
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continents =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Retrieve all continents
|
||||
let all () =
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|
||||
|> Sql.executeAsync toDocument<Continent>
|
||||
|
||||
/// Retrieve a continent by its ID
|
||||
let findById continentId =
|
||||
dataSource () |> getDocument<Continent> Table.Continent (ContinentId.toString continentId)
|
||||
496
src/JobsJobsJobs/Common/Domain.fs
Normal file
496
src/JobsJobsJobs/Common/Domain.fs
Normal file
@@ -0,0 +1,496 @@
|
||||
namespace JobsJobsJobs.Domain
|
||||
|
||||
open System
|
||||
open Giraffe
|
||||
open NodaTime
|
||||
|
||||
// ~~~ SUPPORT TYPES ~~~ //
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
|
||||
/// Support functions for citizen IDs
|
||||
module CitizenId =
|
||||
|
||||
/// Create a new citizen ID
|
||||
let create () = (Guid.NewGuid >> CitizenId) ()
|
||||
|
||||
/// A string representation of a citizen ID
|
||||
let toString = function CitizenId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a citizen ID
|
||||
let ofString = ShortGuid.toGuid >> CitizenId
|
||||
|
||||
/// Get the GUID value of a citizen ID
|
||||
let value = function CitizenId guid -> guid
|
||||
|
||||
|
||||
/// Types of contacts supported by Jobs, Jobs, Jobs
|
||||
type ContactType =
|
||||
/// E-mail addresses
|
||||
| Email
|
||||
/// Phone numbers (home, work, cell, etc.)
|
||||
| Phone
|
||||
/// Websites (personal, social, etc.)
|
||||
| Website
|
||||
|
||||
/// Functions to support contact types
|
||||
module ContactType =
|
||||
|
||||
/// Parse a contact type from a string
|
||||
let parse typ =
|
||||
match typ with
|
||||
| "Email" -> Email
|
||||
| "Phone" -> Phone
|
||||
| "Website" -> Website
|
||||
| it -> invalidOp $"{it} is not a valid contact type"
|
||||
|
||||
/// Convert a contact type to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Email -> "Email"
|
||||
| Phone -> "Phone"
|
||||
| Website -> "Website"
|
||||
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
/// Support functions for continent IDs
|
||||
module ContinentId =
|
||||
|
||||
/// Create a new continent ID
|
||||
let create () = (Guid.NewGuid >> ContinentId) ()
|
||||
|
||||
/// A string representation of a continent ID
|
||||
let toString = function ContinentId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a continent ID
|
||||
let ofString = ShortGuid.toGuid >> ContinentId
|
||||
|
||||
/// Get the GUID value of a continent ID
|
||||
let value = function ContinentId guid -> guid
|
||||
|
||||
|
||||
/// A string of Markdown text
|
||||
type MarkdownString = Text of string
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
|
||||
open Markdig
|
||||
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
|
||||
/// Convert this Markdown string to HTML
|
||||
let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
|
||||
|
||||
/// Convert a Markdown string to its string representation
|
||||
let toString = function Text text -> text
|
||||
|
||||
|
||||
/// An employment history entry
|
||||
[<NoComparison; NoEquality>]
|
||||
type EmploymentHistory =
|
||||
{ /// The employer for this period of employment
|
||||
Employer : string
|
||||
|
||||
/// The date employment started
|
||||
StartDate : LocalDate
|
||||
|
||||
/// The date employment ended (None implies ongoing employment)
|
||||
EndDate : LocalDate option
|
||||
|
||||
/// The title / position held
|
||||
Position : string option
|
||||
|
||||
/// A description of the duties entailed during this employment
|
||||
Description : MarkdownString option
|
||||
}
|
||||
|
||||
/// Support functions for employment history entries
|
||||
module EmploymentHistory =
|
||||
|
||||
let empty =
|
||||
{ Employer = ""
|
||||
StartDate = LocalDate.FromDateTime DateTime.Today
|
||||
EndDate = None
|
||||
Position = None
|
||||
Description = None
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a job listing
|
||||
type ListingId = ListingId of Guid
|
||||
|
||||
/// Support functions for listing IDs
|
||||
module ListingId =
|
||||
|
||||
/// Create a new job listing ID
|
||||
let create () = (Guid.NewGuid >> ListingId) ()
|
||||
|
||||
/// A string representation of a listing ID
|
||||
let toString = function ListingId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a listing ID
|
||||
let ofString = ShortGuid.toGuid >> ListingId
|
||||
|
||||
/// Get the GUID value of a listing ID
|
||||
let value = function ListingId guid -> guid
|
||||
|
||||
|
||||
/// Another way to contact a citizen from this site
|
||||
[<NoComparison; NoEquality>]
|
||||
type OtherContact =
|
||||
{ /// The type of contact
|
||||
ContactType : ContactType
|
||||
|
||||
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
|
||||
Name : string option
|
||||
|
||||
/// The value for the contact (e-mail address, user name, URL, etc.)
|
||||
Value : string
|
||||
|
||||
/// Whether this contact is visible in public employment profiles and job listings
|
||||
IsPublic : bool
|
||||
}
|
||||
|
||||
|
||||
/// Visibility options for an employment profile
|
||||
type ProfileVisibility =
|
||||
/// Profile is only visible to the citizen to whom it belongs
|
||||
| Hidden
|
||||
/// Profile is only visible to authenticated users
|
||||
| Private
|
||||
/// Anonymous information is visible to public users
|
||||
| Anonymous
|
||||
/// The full employment profile is visible to public users
|
||||
| Public
|
||||
|
||||
/// Support functions for profile visibility
|
||||
module ProfileVisibility =
|
||||
|
||||
/// Parse a string into a profile visibility
|
||||
let parse viz =
|
||||
match viz with
|
||||
| "Hidden" -> Hidden
|
||||
| "Private" -> Private
|
||||
| "Anonymous" -> Anonymous
|
||||
| "Public" -> Public
|
||||
| it -> invalidOp $"{it} is not a valid profile visibility value"
|
||||
|
||||
/// Convert a profile visibility to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Hidden -> "Hidden"
|
||||
| Private -> "Private"
|
||||
| Anonymous -> "Anonymous"
|
||||
| Public -> "Public"
|
||||
|
||||
|
||||
/// A skill the job seeker possesses
|
||||
[<NoComparison; NoEquality>]
|
||||
type Skill =
|
||||
{ /// A description of the skill
|
||||
Description : string
|
||||
|
||||
/// Notes regarding this skill (level, duration, etc.)
|
||||
Notes : string option
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a success report
|
||||
type SuccessId = SuccessId of Guid
|
||||
|
||||
/// Support functions for success report IDs
|
||||
module SuccessId =
|
||||
|
||||
/// Create a new success report ID
|
||||
let create () = (Guid.NewGuid >> SuccessId) ()
|
||||
|
||||
/// A string representation of a success report ID
|
||||
let toString = function SuccessId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a success report ID
|
||||
let ofString = ShortGuid.toGuid >> SuccessId
|
||||
|
||||
/// Get the GUID value of a success ID
|
||||
let value = function SuccessId guid -> guid
|
||||
|
||||
// ~~~ DOCUMENT TYPES ~~~ //
|
||||
|
||||
/// A user of Jobs, Jobs, Jobs; a citizen of Gitmo Nation
|
||||
[<NoComparison; NoEquality>]
|
||||
type Citizen =
|
||||
{ /// The ID of the user
|
||||
Id : CitizenId
|
||||
|
||||
/// When the user joined Jobs, Jobs, Jobs
|
||||
JoinedOn : Instant
|
||||
|
||||
/// When the user last logged in
|
||||
LastSeenOn : Instant
|
||||
|
||||
/// The user's e-mail address
|
||||
Email : string
|
||||
|
||||
/// The user's first name
|
||||
FirstName : string
|
||||
|
||||
/// The user's last name
|
||||
LastName : string
|
||||
|
||||
/// The hash of the user's password
|
||||
PasswordHash : string
|
||||
|
||||
/// The name displayed for this user throughout the site
|
||||
DisplayName : string option
|
||||
|
||||
/// The other contacts for this user
|
||||
OtherContacts : OtherContact list
|
||||
|
||||
/// Whether this is a legacy citizen
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for citizens
|
||||
module Citizen =
|
||||
|
||||
/// An empty citizen
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
JoinedOn = Instant.MinValue
|
||||
LastSeenOn = Instant.MinValue
|
||||
Email = ""
|
||||
FirstName = ""
|
||||
LastName = ""
|
||||
PasswordHash = ""
|
||||
DisplayName = None
|
||||
OtherContacts = []
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
/// Get the name of the citizen (either their preferred display name or first/last names)
|
||||
let name x =
|
||||
match x.DisplayName with Some it -> it | None -> $"{x.FirstName} {x.LastName}"
|
||||
|
||||
|
||||
/// A continent
|
||||
[<NoComparison; NoEquality>]
|
||||
type Continent =
|
||||
{ /// The ID of the continent
|
||||
Id : ContinentId
|
||||
|
||||
/// The name of the continent
|
||||
Name : string
|
||||
}
|
||||
|
||||
/// Support functions for continents
|
||||
module Continent =
|
||||
|
||||
/// An empty continent
|
||||
let empty ={
|
||||
Id = ContinentId Guid.Empty
|
||||
Name = ""
|
||||
}
|
||||
|
||||
|
||||
/// A job listing
|
||||
[<NoComparison; NoEquality>]
|
||||
type Listing =
|
||||
{ /// The ID of the job listing
|
||||
Id : ListingId
|
||||
|
||||
/// The ID of the citizen who posted the job listing
|
||||
CitizenId : CitizenId
|
||||
|
||||
/// When this job listing was created
|
||||
CreatedOn : Instant
|
||||
|
||||
/// The short title of the job listing
|
||||
Title : string
|
||||
|
||||
/// The ID of the continent on which the job is located
|
||||
ContinentId : ContinentId
|
||||
|
||||
/// The region in which the job is located
|
||||
Region : string
|
||||
|
||||
/// Whether this listing is for remote work
|
||||
IsRemote : bool
|
||||
|
||||
/// Whether this listing has expired
|
||||
IsExpired : bool
|
||||
|
||||
/// When this listing was last updated
|
||||
UpdatedOn : Instant
|
||||
|
||||
/// The details of this job
|
||||
Text : MarkdownString
|
||||
|
||||
/// When this job needs to be filled
|
||||
NeededBy : LocalDate option
|
||||
|
||||
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
||||
WasFilledHere : bool option
|
||||
|
||||
/// Whether this is a legacy listing
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for job listings
|
||||
module Listing =
|
||||
|
||||
/// An empty job listing
|
||||
let empty = {
|
||||
Id = ListingId Guid.Empty
|
||||
CitizenId = CitizenId Guid.Empty
|
||||
CreatedOn = Instant.MinValue
|
||||
Title = ""
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsRemote = false
|
||||
IsExpired = false
|
||||
UpdatedOn = Instant.MinValue
|
||||
Text = Text ""
|
||||
NeededBy = None
|
||||
WasFilledHere = None
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
/// Security settings for a user
|
||||
[<NoComparison; NoEquality>]
|
||||
type SecurityInfo =
|
||||
{ /// The ID of the citizen to whom these settings apply
|
||||
Id : CitizenId
|
||||
|
||||
/// The number of failed log on attempts (reset to 0 on successful log on)
|
||||
FailedLogOnAttempts : int
|
||||
|
||||
/// Whether the account is locked
|
||||
AccountLocked : bool
|
||||
|
||||
/// The token the user must provide to take their desired action
|
||||
Token : string option
|
||||
|
||||
/// The action to which the token applies
|
||||
TokenUsage : string option
|
||||
|
||||
/// When the token expires
|
||||
TokenExpires : Instant option
|
||||
}
|
||||
|
||||
/// Functions to support security info
|
||||
module SecurityInfo =
|
||||
|
||||
/// An empty set of security info
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
FailedLogOnAttempts = 0
|
||||
AccountLocked = false
|
||||
Token = None
|
||||
TokenUsage = None
|
||||
TokenExpires = None
|
||||
}
|
||||
|
||||
|
||||
/// A job seeker profile
|
||||
[<NoComparison; NoEquality>]
|
||||
type Profile =
|
||||
{ /// The ID of the citizen to whom this profile belongs
|
||||
Id : CitizenId
|
||||
|
||||
/// The ID of the continent on which the citizen resides
|
||||
ContinentId : ContinentId
|
||||
|
||||
/// The region in which the citizen resides
|
||||
Region : string
|
||||
|
||||
/// Whether this citizen is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether the citizen is interested in remote work
|
||||
IsRemote : bool
|
||||
|
||||
/// Whether the citizen is interested in full-time work
|
||||
IsFullTime : bool
|
||||
|
||||
/// The citizen's professional biography
|
||||
Biography : MarkdownString
|
||||
|
||||
/// Skills this citizen possesses
|
||||
Skills : Skill list
|
||||
|
||||
/// The citizen's employment history
|
||||
History : EmploymentHistory list
|
||||
|
||||
/// The citizen's experience (topical / chronological)
|
||||
Experience : MarkdownString option
|
||||
|
||||
/// The visibility of this profile
|
||||
Visibility : ProfileVisibility
|
||||
|
||||
/// When the citizen last updated their profile
|
||||
LastUpdatedOn : Instant
|
||||
|
||||
/// Whether this is a legacy profile
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
|
||||
// An empty profile
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsSeekingEmployment = false
|
||||
IsRemote = false
|
||||
IsFullTime = false
|
||||
Biography = Text ""
|
||||
Skills = []
|
||||
History = []
|
||||
Experience = None
|
||||
Visibility = Private
|
||||
LastUpdatedOn = Instant.MinValue
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
/// A record of success finding employment
|
||||
[<NoComparison; NoEquality>]
|
||||
type Success =
|
||||
{ /// The ID of the success report
|
||||
Id : SuccessId
|
||||
|
||||
/// The ID of the citizen who wrote this success report
|
||||
CitizenId : CitizenId
|
||||
|
||||
/// When this success report was recorded
|
||||
RecordedOn : Instant
|
||||
|
||||
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
|
||||
IsFromHere : bool
|
||||
|
||||
/// The source of this success (listing or profile)
|
||||
Source : string
|
||||
|
||||
/// The success story
|
||||
Story : MarkdownString option
|
||||
}
|
||||
|
||||
/// Support functions for success stories
|
||||
module Success =
|
||||
|
||||
/// An empty success story
|
||||
let empty = {
|
||||
Id = SuccessId Guid.Empty
|
||||
CitizenId = CitizenId Guid.Empty
|
||||
RecordedOn = Instant.MinValue
|
||||
IsFromHere = false
|
||||
Source = ""
|
||||
Story = None
|
||||
}
|
||||
94
src/JobsJobsJobs/Common/Email.fs
Normal file
94
src/JobsJobsJobs/Common/Email.fs
Normal file
@@ -0,0 +1,94 @@
|
||||
module JobsJobsJobs.Email
|
||||
|
||||
open System.Net
|
||||
open JobsJobsJobs.Domain
|
||||
open MailKit.Net.Smtp
|
||||
open MailKit.Security
|
||||
open MimeKit
|
||||
|
||||
/// Private functions for sending e-mail
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// Create an SMTP client
|
||||
let smtpClient () = backgroundTask {
|
||||
let client = new SmtpClient ()
|
||||
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
|
||||
return client
|
||||
}
|
||||
|
||||
/// Create a message with to, from, and subject completed
|
||||
let createMessage citizen subject =
|
||||
let msg = new MimeMessage ()
|
||||
msg.From.Add (MailboxAddress ("Jobs, Jobs, Jobs", "daniel@bitbadger.solutions"))
|
||||
msg.To.Add (MailboxAddress (Citizen.name citizen, citizen.Email))
|
||||
msg.Subject <- subject
|
||||
msg
|
||||
|
||||
|
||||
/// Send an account confirmation e-mail
|
||||
let sendAccountConfirmation citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Account Confirmation Request"
|
||||
|
||||
let text =
|
||||
[ $"ITM, {Citizen.name citizen}!"
|
||||
""
|
||||
"This e-mail address was recently used to establish an account on"
|
||||
"Jobs, Jobs, Jobs (noagendacareers.com). Before this account can be"
|
||||
"used, it needs to be verified. Please click the link below to do so;"
|
||||
"it will work for the next 72 hours (3 days)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/confirm/{token}"
|
||||
""
|
||||
"If you did not take this action, you can do nothing, and the account"
|
||||
"will be deleted at the end of that time. If you wish to delete it"
|
||||
"immediately, use the link below (also valid for 72 hours)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/deny/{token}"
|
||||
""
|
||||
"TYFYC!"
|
||||
""
|
||||
"--"
|
||||
"Jobs, Jobs, Jobs"
|
||||
"https://noagendacareers.com"
|
||||
] |> String.concat "\n"
|
||||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
|
||||
/// Send a password reset link
|
||||
let sendPasswordReset citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs"
|
||||
|
||||
let text =
|
||||
[ $"ITM, {Citizen.name citizen}!"
|
||||
""
|
||||
"We recently receive a request to reset the password for your account"
|
||||
"on Jobs, Jobs, Jobs (noagendacareers.com). Use the link below to"
|
||||
"do so; it will work for the next 72 hours (3 days)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/reset-password/{token}"
|
||||
""
|
||||
"If you did not take this action, you can do nothing, and the link"
|
||||
"will expire normally. If you wish to expire the token immediately,"
|
||||
"use the link below (also valid for 72 hours)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/cancel-reset/{token}"
|
||||
""
|
||||
"TYFYC!"
|
||||
""
|
||||
"--"
|
||||
"Jobs, Jobs, Jobs"
|
||||
"https://noagendacareers.com"
|
||||
] |> String.concat "\n"
|
||||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
206
src/JobsJobsJobs/Common/Handlers.fs
Normal file
206
src/JobsJobsJobs/Common/Handlers.fs
Normal file
@@ -0,0 +1,206 @@
|
||||
/// Common helper functions for views
|
||||
module JobsJobsJobs.Common.Handlers
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
[<AutoOpen>]
|
||||
module HtmxHelpers =
|
||||
|
||||
/// Is the request from htmx?
|
||||
let isHtmx (ctx : HttpContext) =
|
||||
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
|
||||
open System.Net
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler = fun _ ctx ->
|
||||
let fac = ctx.GetService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "Handler"
|
||||
let path = string ctx.Request.Path
|
||||
log.LogInformation "Returning 404"
|
||||
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx
|
||||
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx ->
|
||||
if ctx.Request.Method = "GET" then
|
||||
let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
|
||||
else redirectTo false redirectUrl next ctx
|
||||
else
|
||||
if isHtmx ctx then
|
||||
(setHttpHeader "X-Toast" $"error|||You are not authorized to access the URL {ctx.Request.Path.Value}"
|
||||
>=> setStatusCode 401) earlyReturn ctx
|
||||
else setStatusCode 401 earlyReturn ctx
|
||||
|
||||
/// Handler to log 500s and return a message we can display in the application
|
||||
let unexpectedError (ex: exn) (log : ILogger) =
|
||||
log.LogError(ex, "An unexpected error occurred")
|
||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||
|
||||
|
||||
open System
|
||||
open System.Security.Claims
|
||||
open System.Text.Json
|
||||
open System.Text.RegularExpressions
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open NodaTime
|
||||
|
||||
/// Get the NodaTime clock from the request context
|
||||
let now (ctx : HttpContext) = ctx.GetService<IClock>().GetCurrentInstant ()
|
||||
|
||||
/// Get the application configuration from the request context
|
||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||
|
||||
/// Get the logger factory from the request context
|
||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||
|
||||
/// `None` if a `string option` is `None`, whitespace, or empty
|
||||
let noneIfBlank (s : string option) =
|
||||
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
||||
|
||||
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
||||
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
||||
|
||||
/// Try to get the current user
|
||||
let tryUser (ctx : HttpContext) =
|
||||
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun x -> x.Value)
|
||||
|
||||
/// Get the ID of the currently logged in citizen
|
||||
// NOTE: if no one is logged in, this will raise an exception
|
||||
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
||||
|
||||
let antiForgerySvc (ctx : HttpContext) =
|
||||
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Obtain an anti-forgery token set
|
||||
let csrf ctx =
|
||||
(antiForgerySvc ctx).GetAndStoreTokens ctx
|
||||
|
||||
/// Get the time zone from the citizen's browser
|
||||
let timeZone (ctx : HttpContext) =
|
||||
let tz = string ctx.Request.Headers["X-Time-Zone"]
|
||||
defaultArg (noneIfEmpty tz) "Etc/UTC"
|
||||
|
||||
/// The key to use to indicate if we have loaded the session
|
||||
let private sessionLoadedKey = "session-loaded"
|
||||
|
||||
/// Load the session if we have not yet
|
||||
let private loadSession (ctx : HttpContext) = task {
|
||||
if not (ctx.Items.ContainsKey sessionLoadedKey) then
|
||||
do! ctx.Session.LoadAsync ()
|
||||
ctx.Items.Add (sessionLoadedKey, "yes")
|
||||
}
|
||||
|
||||
/// Save the session if we have loaded it
|
||||
let private saveSession (ctx : HttpContext) = task {
|
||||
if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync ()
|
||||
}
|
||||
|
||||
/// Get the messages from the session (destructively)
|
||||
let popMessages ctx = task {
|
||||
do! loadSession ctx
|
||||
let msgs =
|
||||
match ctx.Session.GetString "messages" with
|
||||
| null -> []
|
||||
| m -> JsonSerializer.Deserialize<string list> m
|
||||
if not (List.isEmpty msgs) then ctx.Session.Remove "messages"
|
||||
return List.rev msgs
|
||||
}
|
||||
|
||||
/// Add a message to the response
|
||||
let addMessage (level : string) (msg : string) ctx = task {
|
||||
do! loadSession ctx
|
||||
let! msgs = popMessages ctx
|
||||
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
|
||||
}
|
||||
|
||||
/// Add a success message to the response
|
||||
let addSuccess msg ctx = task {
|
||||
do! addMessage "success" msg ctx
|
||||
}
|
||||
|
||||
/// Add an error message to the response
|
||||
let addError msg ctx = task {
|
||||
do! addMessage "error" msg ctx
|
||||
}
|
||||
|
||||
/// Add a list of errors to the response
|
||||
let addErrors (errors : string list) ctx = task {
|
||||
let errMsg = String.Join ("</li><li>", errors)
|
||||
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
|
||||
}
|
||||
|
||||
open JobsJobsJobs.Common.Views
|
||||
|
||||
/// Create the render context for an HTML response
|
||||
let private createContext (ctx : HttpContext) pageTitle content messages : Layout.PageRenderContext =
|
||||
{ IsLoggedOn = Option.isSome (tryUser ctx)
|
||||
CurrentUrl = ctx.Request.Path.Value
|
||||
PageTitle = pageTitle
|
||||
Content = content
|
||||
Messages = messages
|
||||
}
|
||||
|
||||
/// Render a page-level view
|
||||
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
|
||||
let! messages = popMessages ctx
|
||||
let renderCtx = createContext ctx pageTitle content messages
|
||||
let renderFunc = if isHtmx ctx then Layout.partial else Layout.full
|
||||
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
||||
}
|
||||
|
||||
/// Render a printable view (content with styles, but no layout)
|
||||
let renderPrint pageTitle (_ : HttpFunc) (ctx : HttpContext) content =
|
||||
createContext ctx pageTitle content []
|
||||
|> Layout.print
|
||||
|> ctx.WriteHtmlViewAsync
|
||||
|
||||
/// Render a bare (component) view
|
||||
let renderBare (_ : HttpFunc) (ctx : HttpContext) content =
|
||||
createContext ctx "" content []
|
||||
|> Layout.bare
|
||||
|> ctx.WriteHtmlViewAsync
|
||||
|
||||
/// Render as a composable HttpHandler
|
||||
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
|
||||
render pageTitle next ctx content
|
||||
|
||||
/// Validate the anti cross-site request forgery token in the current request
|
||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||
match! (antiForgerySvc ctx).IsRequestValidAsync ctx with
|
||||
| true -> return! next ctx
|
||||
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
|
||||
}
|
||||
|
||||
/// Require a user to be logged on for a route
|
||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||
|
||||
/// Regular expression to validate that a URL is a local URL
|
||||
let isLocal = Regex """^/[^\/\\].*"""
|
||||
|
||||
/// Redirect to another page, saving the session before redirecting
|
||||
let redirectToGet (url : string) next ctx = task {
|
||||
do! saveSession ctx
|
||||
let action =
|
||||
if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then
|
||||
if isHtmx ctx then withHxRedirect url else redirectTo false url
|
||||
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
|
||||
return! action next ctx
|
||||
}
|
||||
|
||||
/// Shorthand for Error.notFound for use in handler functions
|
||||
let notFound ctx =
|
||||
Error.notFound earlyReturn ctx
|
||||
31
src/JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj
Normal file
31
src/JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Email.fs" />
|
||||
<Compile Include="Json.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Cache.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.0.7" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="MailKit" Version="3.3.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.4" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.6.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
34
src/JobsJobsJobs/Common/Json.fs
Normal file
34
src/JobsJobsJobs/Common/Json.fs
Normal file
@@ -0,0 +1,34 @@
|
||||
/// JSON serializer options
|
||||
module JobsJobsJobs.Json
|
||||
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Convert a wrapped DU to/from its string representation
|
||||
type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) =
|
||||
inherit JsonConverter<'T> ()
|
||||
override _.Read(reader, _, _) =
|
||||
wrap (reader.GetString ())
|
||||
override _.Write(writer, value, _) =
|
||||
writer.WriteStringValue (unwrap value)
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.SystemTextJson
|
||||
|
||||
/// JsonSerializer options that use the custom converters
|
||||
let options =
|
||||
let opts = JsonSerializerOptions ()
|
||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
|
||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||
JsonFSharpConverter ()
|
||||
]
|
||||
|> List.iter opts.Converters.Add
|
||||
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||
opts
|
||||
393
src/JobsJobsJobs/Common/Views.fs
Normal file
393
src/JobsJobsJobs/Common/Views.fs
Normal file
@@ -0,0 +1,393 @@
|
||||
/// Common functions for views
|
||||
module JobsJobsJobs.Common.Views
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Create an audio clip with the specified text node
|
||||
let audioClip clip text =
|
||||
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
|
||||
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ]
|
||||
]
|
||||
|
||||
/// Create an anti-forgery hidden input
|
||||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||
|
||||
/// Alias for rawText
|
||||
let txt = rawText
|
||||
|
||||
/// Create a page with a title displayed on the page
|
||||
let pageWithTitle title content =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ txt title ]
|
||||
yield! content
|
||||
]
|
||||
|
||||
/// Create a floating-label text input box
|
||||
let textBox attrs name value fieldLabel isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
List.append attrs [
|
||||
_id name; _name name; _class "form-control"; _placeholder fieldLabel; _value value
|
||||
if isRequired then _required
|
||||
] |> input
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt fieldLabel ]
|
||||
]
|
||||
|
||||
/// Create a checkbox that will post "true" if checked
|
||||
let checkBox attrs name isChecked checkLabel =
|
||||
div [ _class "form-check" ] [
|
||||
List.append attrs
|
||||
[ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
|
||||
if isChecked then _checked ]
|
||||
|> input
|
||||
label [ _class "form-check-label"; _for name ] [ txt checkLabel ]
|
||||
]
|
||||
|
||||
/// Create a select list of continents
|
||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
select (List.append attrs [ _id name; _name name; _class "form-select"; if isRequired then _required ]) (
|
||||
option [ _value ""; if selectedValue = "" then _selected ] [
|
||||
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
||||
:: (continents
|
||||
|> List.map (fun c ->
|
||||
let theId = ContinentId.toString c.Id
|
||||
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt "Continent" ]
|
||||
]
|
||||
|
||||
/// Create a submit button with the given icon and text
|
||||
let submitButton icon text =
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ i [ _class $"mdi mdi-%s{icon}" ] []; txt $" %s{text}" ]
|
||||
|
||||
/// An empty paragraph
|
||||
let emptyP =
|
||||
p [] [ txt " " ]
|
||||
|
||||
/// Register JavaScript code to run in the DOMContentLoaded event on the page
|
||||
let jsOnLoad js isHtmx =
|
||||
script [] [
|
||||
let (target, event) = if isHtmx then "document.body", "htmx:afterSettle" else "document", "DOMContentLoaded"
|
||||
txt (sprintf """%s.addEventListener("%s", () => { %s }, { once: true })""" target event js)
|
||||
]
|
||||
|
||||
/// Create a Markdown editor
|
||||
let markdownEditor attrs name value editorLabel isHtmx =
|
||||
div [ _class "col-12"; _id $"{name}EditRow" ] [
|
||||
nav [ _class "nav nav-pills pb-1" ] [
|
||||
button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [
|
||||
txt "Markdown"
|
||||
]
|
||||
rawText " "
|
||||
button [ _type "button"; _id $"{name}PreviewButton"
|
||||
_class "btn btn-outline-secondary btn-sm rounded-pill" ] [
|
||||
txt "Preview"
|
||||
]
|
||||
]
|
||||
section [ _id $"{name}Preview"; _class "jjj-not-shown jjj-markdown-preview px-2 pt-2"
|
||||
_ariaLabel "Rendered Markdown preview" ] []
|
||||
div [ _id $"{name}Edit"; _class "form-floating jjj-shown" ] [
|
||||
textarea (List.append attrs
|
||||
[ _id name; _name name; _class "form-control jjj-markdown-editor"; _rows "10" ]) [
|
||||
txt value
|
||||
]
|
||||
label [ _for name ] [ txt editorLabel ]
|
||||
]
|
||||
jsOnLoad $"jjj.markdownOnLoad('{name}')" isHtmx
|
||||
]
|
||||
|
||||
/// Wrap content in a collapsing panel
|
||||
let collapsePanel header isShown content =
|
||||
let showClass = if isShown then " show" else ""
|
||||
div [ _class "card" ] [
|
||||
div [ _class "card-header" ] [
|
||||
h6 [ _class "mb-0 card-title" ] [
|
||||
a [ _href "#jjjCollapse"; _data "bs-toggle" "collapse"; _roleButton; _ariaControls "#jjjCollapse"
|
||||
_ariaExpanded (isShown.ToString().ToLowerInvariant ()) ] [ txt header ]
|
||||
]
|
||||
]
|
||||
div [ _id "jjjCollapse"; _class $"card-body collapse{showClass}" ] content
|
||||
]
|
||||
|
||||
/// "Yes" or "No" based on a boolean value
|
||||
let yesOrNo value =
|
||||
if value then "Yes" else "No"
|
||||
|
||||
/// Markdown as a raw HTML text node
|
||||
let md2html value =
|
||||
(MarkdownString.toHtml >> txt) value
|
||||
|
||||
/// Display a citizen's contact information
|
||||
let contactInfo citizen isPublic =
|
||||
citizen.OtherContacts
|
||||
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|
||||
|> List.collect (fun contact ->
|
||||
match contact.ContactType with
|
||||
| Website ->
|
||||
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "
|
||||
a [ _href contact.Value; _target "_blank"; _rel "noopener"; _class "me-4" ] [
|
||||
str (defaultArg contact.Name "Website")
|
||||
]
|
||||
]
|
||||
| Email ->
|
||||
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "
|
||||
a [ _href $"mailto:{contact.Value}"; _class "me-4" ] [ str (defaultArg contact.Name "E-mail") ]
|
||||
]
|
||||
| Phone ->
|
||||
[ span [ _class "me-4" ] [
|
||||
i [ _class "mdi mdi-sm mdi-phone" ] []; txt " "; str contact.Value
|
||||
match contact.Name with Some name -> str $" ({name})" | None -> ()
|
||||
]
|
||||
])
|
||||
|
||||
/// Display a citizen's contact information
|
||||
let contactInfoPrint citizen isPublic =
|
||||
citizen.OtherContacts
|
||||
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|
||||
|> List.collect (fun contact ->
|
||||
match contact.ContactType with
|
||||
| Website ->
|
||||
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "; str (defaultArg contact.Name "Website"); txt " – "
|
||||
str contact.Value; br []
|
||||
]
|
||||
| Email ->
|
||||
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "; str (defaultArg contact.Name "E-mail")
|
||||
txt " – "; str contact.Value; br []
|
||||
]
|
||||
| Phone ->
|
||||
[ span [ _class "me-4" ] [
|
||||
i [ _class "mdi mdi-sm mdi-phone" ] []; rawText " "
|
||||
match contact.Name with Some name -> str name; txt " – " | None -> ()
|
||||
str contact.Value; br []
|
||||
]
|
||||
])
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// Generate a full date in the citizen's local time zone
|
||||
let fullDate (value : Instant) tz =
|
||||
(ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb))
|
||||
.Format(value.InZone DateTimeZoneProviders.Tzdb[tz])
|
||||
|
||||
/// Generate a full date/time in the citizen's local time
|
||||
let fullDateTime (value : Instant) tz =
|
||||
let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb)
|
||||
let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb)
|
||||
let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz]
|
||||
$"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}"
|
||||
|
||||
|
||||
/// Layout generation functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Layout =
|
||||
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
|
||||
/// Data items needed to render a view
|
||||
type PageRenderContext =
|
||||
{ /// Whether a user is logged on
|
||||
IsLoggedOn : bool
|
||||
|
||||
/// The current URL
|
||||
CurrentUrl : string
|
||||
|
||||
/// The title of this page
|
||||
PageTitle : string
|
||||
|
||||
/// The page content
|
||||
Content : XmlNode
|
||||
|
||||
/// User messages to be displayed
|
||||
Messages : string list
|
||||
}
|
||||
|
||||
/// Append the application name to the page title
|
||||
let private constructTitle ctx =
|
||||
seq {
|
||||
if ctx.PageTitle <> "" then
|
||||
ctx.PageTitle; " | "
|
||||
"Jobs, Jobs, Jobs"
|
||||
}
|
||||
|> Seq.reduce (+)
|
||||
|> str
|
||||
|> List.singleton
|
||||
|> title []
|
||||
|
||||
/// Generate the HTML head tag
|
||||
let private htmlHead ctx =
|
||||
head [] [
|
||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
constructTitle ctx
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"
|
||||
_rel "stylesheet"
|
||||
_integrity "sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"
|
||||
_crossorigin "anonymous" ]
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
|
||||
_rel "stylesheet" ]
|
||||
link [ _href "/style.css"; _rel "stylesheet" ]
|
||||
]
|
||||
|
||||
/// Display the links available to the current user
|
||||
let private links ctx =
|
||||
let navLink url icon text =
|
||||
a [ _href url
|
||||
_onclick "jjj.hideMenu()"
|
||||
if url = ctx.CurrentUrl then _class "jjj-current-page"
|
||||
] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; txt text ]
|
||||
nav [ _class "jjj-nav" ] [
|
||||
if ctx.IsLoggedOn then
|
||||
navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard"
|
||||
navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!"
|
||||
navLink "/profile/search" "view-list-outline" "Job Seekers"
|
||||
navLink "/success-stories" "thumb-up" "Success Stories"
|
||||
div [ _class "separator" ] []
|
||||
navLink "/citizen/account" "account-edit" "My Account"
|
||||
navLink "/listings/mine" "sign-text" "My Job Listings"
|
||||
navLink "/profile/edit" "pencil" "My Employment Profile"
|
||||
div [ _class "separator" ] []
|
||||
navLink "/citizen/log-off" "logout-variant" "Log Off"
|
||||
else
|
||||
navLink "/" "home" "Home"
|
||||
navLink "/profile/search" "view-list-outline" "Job Seekers"
|
||||
navLink "/citizen/log-on" "login-variant" "Log On"
|
||||
navLink "/how-it-works" "help-circle-outline" "How It Works"
|
||||
]
|
||||
|
||||
/// Generate mobile and desktop side navigation areas
|
||||
let private sideNavs ctx = [
|
||||
div [ _id "mobileMenu"; _class "jjj-mobile-menu offcanvas offcanvas-end"; _tabindex "-1"
|
||||
_ariaLabelledBy "mobileMenuLabel" ] [
|
||||
div [ _class "offcanvas-header" ] [
|
||||
h5 [ _id "mobileMenuLabel" ] [ txt "Menu" ]
|
||||
button [
|
||||
_class "btn-close text-reset"; _type "button"; _data "bs-dismiss" "offcanvas"; _ariaLabel "Close"
|
||||
] []
|
||||
]
|
||||
div [ _class "offcanvas-body" ] [ links ctx ]
|
||||
]
|
||||
aside [ _class "jjj-full-menu d-none d-md-block p-3" ] [
|
||||
p [ _class "home-link pb-3" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
|
||||
emptyP
|
||||
links ctx
|
||||
]
|
||||
]
|
||||
|
||||
/// Title bars for mobile and desktop
|
||||
let private titleBars = [
|
||||
nav [ _class "d-flex d-md-none navbar navbar-dark" ] [
|
||||
span [ _class "navbar-text" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
|
||||
button [ _class "btn"; _data "bs-toggle" "offcanvas"; _data "bs-target" "#mobileMenu"
|
||||
_ariaControls "mobileMenu" ] [ i [ _class "mdi mdi-menu" ] [] ]
|
||||
]
|
||||
nav [ _class "d-none d-md-flex navbar navbar-light bg-light"] [
|
||||
span [] [ txt " " ]
|
||||
span [ _class "navbar-text" ] [
|
||||
txt "(…and Jobs – "; audioClip "pelosi-jobs" (txt "Let’s Vote for Jobs!"); txt ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The HTML footer for the page
|
||||
let private htmlFoot =
|
||||
let v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
|
||||
let version =
|
||||
seq {
|
||||
string v.Major
|
||||
if v.Minor > 0 then
|
||||
"."; string v.Minor
|
||||
if v.Build > 0 then
|
||||
"."; string v.Build
|
||||
} |> Seq.reduce (+)
|
||||
footer [] [
|
||||
p [ _class "text-muted" ] [
|
||||
txt $"Jobs, Jobs, Jobs v{version} • "
|
||||
a [ _href "/privacy-policy" ] [ txt "Privacy Policy" ]; txt " • "
|
||||
a [ _href "/terms-of-service" ] [ txt "Terms of Service" ]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render any messages
|
||||
let private messages ctx =
|
||||
ctx.Messages
|
||||
|> List.map (fun msg ->
|
||||
let parts = msg.Split "|||"
|
||||
let level = if parts[0] = "error" then "danger" else parts[0]
|
||||
let message = parts[1]
|
||||
div [ _class $"alert alert-{level} alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
|
||||
_roleAlert ] [
|
||||
p [ _class "mb-0" ] [
|
||||
if level <> "success" then strong [] [ txt $"{parts[0].ToUpperInvariant ()}: " ]
|
||||
txt message
|
||||
]
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
|
||||
])
|
||||
|> div [ _id "alerts" ]
|
||||
|
||||
/// Create a full view
|
||||
let full ctx =
|
||||
html [ _lang "en" ] [
|
||||
htmlHead ctx
|
||||
body [] [
|
||||
div [ _class "jjj-app"; _hxBoost; _hxTarget "this" ] [
|
||||
yield! sideNavs ctx
|
||||
div [ _class "jjj-main" ] [
|
||||
yield! titleBars
|
||||
main [ _class "jjj-content container-fluid" ] [
|
||||
messages ctx
|
||||
ctx.Content
|
||||
]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
Script.minified
|
||||
script [ _async
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||
_crossorigin "anonymous" ] []
|
||||
script [ _src "/script.js" ] []
|
||||
template [ _id "alertTemplate" ] [
|
||||
div [ _class $"alert alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
|
||||
_roleAlert ] [
|
||||
p [ _class "mb-0" ] []
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a partial (boosted response) view
|
||||
let partial ctx =
|
||||
html [ _lang "en" ] [
|
||||
head [] [
|
||||
constructTitle ctx
|
||||
]
|
||||
body [] [
|
||||
yield! sideNavs ctx
|
||||
div [ _class "jjj-main" ] [
|
||||
yield! titleBars
|
||||
main [ _class "jjj-content container-fluid" ] [
|
||||
messages ctx
|
||||
ctx.Content
|
||||
]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a print view (styles, but no other layout)
|
||||
let print ctx =
|
||||
html [ _lang "en" ] [
|
||||
htmlHead ctx
|
||||
body [ _class "m-1" ] [ ctx.Content ]
|
||||
]
|
||||
|
||||
/// Render a bare view (used for components)
|
||||
let bare ctx =
|
||||
html [ _lang "en" ] [
|
||||
head [] [ title [] [] ]
|
||||
body [] [ ctx.Content ]
|
||||
]
|
||||
@@ -1,9 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<DebugType>embedded</DebugType>
|
||||
<AssemblyVersion>2.2.2.0</AssemblyVersion>
|
||||
<FileVersion>2.2.2.0</FileVersion>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<AssemblyVersion>3.0.0.0</AssemblyVersion>
|
||||
<FileVersion>3.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
1
src/JobsJobsJobs/Domain/.gitignore
vendored
1
src/JobsJobsJobs/Domain/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*.js
|
||||
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Types.fs" />
|
||||
<Compile Include="Modules.fs" />
|
||||
<Compile Include="SharedTypes.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.0" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,99 +0,0 @@
|
||||
/// Modules to provide support functions for types
|
||||
[<AutoOpen>]
|
||||
module JobsJobsJobs.Domain.Modules
|
||||
|
||||
open Markdig
|
||||
open System
|
||||
open Types
|
||||
|
||||
/// Format a GUID as a Short GUID
|
||||
let private toShortGuid (guid : Guid) =
|
||||
Convert.ToBase64String(guid.ToByteArray ()).Replace('/', '_').Replace('+', '-')[0..21]
|
||||
|
||||
/// Turn a Short GUID back into a GUID
|
||||
let private fromShortGuid (it : string) =
|
||||
(Convert.FromBase64String >> Guid) $"{it.Replace('_', '/').Replace('-', '+')}=="
|
||||
|
||||
|
||||
/// Support functions for citizen IDs
|
||||
module CitizenId =
|
||||
/// Create a new citizen ID
|
||||
let create () = (Guid.NewGuid >> CitizenId) ()
|
||||
/// A string representation of a citizen ID
|
||||
let toString = function CitizenId it -> toShortGuid it
|
||||
/// Parse a string into a citizen ID
|
||||
let ofString = fromShortGuid >> CitizenId
|
||||
|
||||
|
||||
/// Support functions for citizens
|
||||
module Citizen =
|
||||
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
|
||||
let name x =
|
||||
[ x.realName; x.displayName; Some x.mastodonUser ]
|
||||
|> List.find Option.isSome
|
||||
|> Option.get
|
||||
|
||||
|
||||
/// Support functions for continent IDs
|
||||
module ContinentId =
|
||||
/// Create a new continent ID
|
||||
let create () = (Guid.NewGuid >> ContinentId) ()
|
||||
/// A string representation of a continent ID
|
||||
let toString = function ContinentId it -> toShortGuid it
|
||||
/// Parse a string into a continent ID
|
||||
let ofString = fromShortGuid >> ContinentId
|
||||
|
||||
|
||||
/// Support functions for listing IDs
|
||||
module ListingId =
|
||||
/// Create a new job listing ID
|
||||
let create () = (Guid.NewGuid >> ListingId) ()
|
||||
/// A string representation of a listing ID
|
||||
let toString = function ListingId it -> toShortGuid it
|
||||
/// Parse a string into a listing ID
|
||||
let ofString = fromShortGuid >> ListingId
|
||||
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
/// Convert this Markdown string to HTML
|
||||
let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
|
||||
|
||||
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
// An empty profile
|
||||
let empty =
|
||||
{ id = CitizenId Guid.Empty
|
||||
seekingEmployment = false
|
||||
isPublic = false
|
||||
continentId = ContinentId Guid.Empty
|
||||
region = ""
|
||||
remoteWork = false
|
||||
fullTime = false
|
||||
biography = Text ""
|
||||
lastUpdatedOn = NodaTime.Instant.MinValue
|
||||
experience = None
|
||||
skills = []
|
||||
}
|
||||
|
||||
/// Support functions for skill IDs
|
||||
module SkillId =
|
||||
/// Create a new skill ID
|
||||
let create () = (Guid.NewGuid >> SkillId) ()
|
||||
/// A string representation of a skill ID
|
||||
let toString = function SkillId it -> toShortGuid it
|
||||
/// Parse a string into a skill ID
|
||||
let ofString = fromShortGuid >> SkillId
|
||||
|
||||
|
||||
/// Support functions for success report IDs
|
||||
module SuccessId =
|
||||
/// Create a new success report ID
|
||||
let create () = (Guid.NewGuid >> SuccessId) ()
|
||||
/// A string representation of a success report ID
|
||||
let toString = function SuccessId it -> toShortGuid it
|
||||
/// Parse a string into a success report ID
|
||||
let ofString = fromShortGuid >> SuccessId
|
||||
@@ -1,287 +0,0 @@
|
||||
/// Types intended to be shared between the API and the client application
|
||||
module JobsJobsJobs.Domain.SharedTypes
|
||||
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Microsoft.Extensions.Options
|
||||
open NodaTime
|
||||
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// 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 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 various ways job listings can be searched
|
||||
[<CLIMutable>]
|
||||
type ListingSearch =
|
||||
{ /// Retrieve job listings for this continent
|
||||
continentId : string option
|
||||
/// Text for a search within a region
|
||||
region : string option
|
||||
/// Whether to retrieve job listings for remote work
|
||||
remoteWork : string
|
||||
/// Text for a search with the job listing description
|
||||
text : string option
|
||||
}
|
||||
|
||||
|
||||
/// A successful logon
|
||||
type LogOnSuccess =
|
||||
{ /// The JSON Web Token (JWT) to use for API access
|
||||
jwt : string
|
||||
/// The ID of the logged-in citizen (as a string)
|
||||
citizenId : string
|
||||
/// The name of the logged-in citizen
|
||||
name : string
|
||||
}
|
||||
|
||||
|
||||
/// A count
|
||||
type Count =
|
||||
{ // The count being returned
|
||||
count : int64
|
||||
}
|
||||
|
||||
|
||||
/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs
|
||||
type MastodonInstance () =
|
||||
/// The name of the instance
|
||||
member val Name = "" with get, set
|
||||
/// The URL for this instance
|
||||
member val Url = "" with get, set
|
||||
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||
member val Abbr = "" with get, set
|
||||
/// The client ID (assigned by the Mastodon server)
|
||||
member val ClientId = "" with get, set
|
||||
/// The cryptographic secret (provided by the Mastodon server)
|
||||
member val Secret = "" with get, set
|
||||
/// Whether the instance is currently enabled
|
||||
member val IsEnabled = true with get, set
|
||||
/// If an instance is disabled, the reason for it being disabled
|
||||
member val Reason = "" with get, set
|
||||
|
||||
|
||||
/// The authorization options for Jobs, Jobs, Jobs
|
||||
type AuthOptions () =
|
||||
/// The host for the return URL for Mastodon verification
|
||||
member val ReturnHost = "" with get, set
|
||||
/// The secret with which the server signs the JWTs for auth once we've verified with Mastodon
|
||||
member val ServerSecret = "" with get, set
|
||||
/// The instances configured for use
|
||||
member val Instances = Array.empty<MastodonInstance> with get, set
|
||||
interface IOptions<AuthOptions> with
|
||||
override this.Value = this
|
||||
|
||||
|
||||
/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API
|
||||
type Instance =
|
||||
{ /// The name of the instance
|
||||
name : string
|
||||
/// The URL for this instance
|
||||
url : string
|
||||
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||
abbr : string
|
||||
/// The client ID (assigned by the Mastodon server)
|
||||
clientId : string
|
||||
/// Whether this instance is currently enabled
|
||||
isEnabled : bool
|
||||
/// If not enabled, the reason the instance is disabled
|
||||
reason : string
|
||||
}
|
||||
|
||||
|
||||
/// The fields required for a skill
|
||||
type SkillForm =
|
||||
{ /// The ID of this skill
|
||||
id : string
|
||||
/// The description of the skill
|
||||
description : string
|
||||
/// Notes regarding the skill
|
||||
notes : string option
|
||||
}
|
||||
|
||||
/// The data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ProfileForm =
|
||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
isSeekingEmployment : bool
|
||||
/// Whether this profile should appear in the public search
|
||||
isPublic : bool
|
||||
/// The user's real name
|
||||
realName : string
|
||||
/// The ID of the continent on which the citizen is located
|
||||
continentId : string
|
||||
/// The area within that continent where the citizen is located
|
||||
region : string
|
||||
/// If the citizen is available for remote work
|
||||
remoteWork : bool
|
||||
/// If the citizen is seeking full-time employment
|
||||
fullTime : bool
|
||||
/// The user's professional biography
|
||||
biography : string
|
||||
/// The user's past experience
|
||||
experience : string option
|
||||
/// The skills for the user
|
||||
skills : SkillForm list
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
module ProfileForm =
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Types.Profile) =
|
||||
{ isSeekingEmployment = profile.seekingEmployment
|
||||
isPublic = profile.isPublic
|
||||
realName = ""
|
||||
continentId = string profile.continentId
|
||||
region = profile.region
|
||||
remoteWork = profile.remoteWork
|
||||
fullTime = profile.fullTime
|
||||
biography = match profile.biography with Text bio -> bio
|
||||
experience = profile.experience |> Option.map (fun x -> match x with Text exp -> exp)
|
||||
skills = profile.skills
|
||||
|> List.map (fun s ->
|
||||
{ id = string s.id
|
||||
description = s.description
|
||||
notes = s.notes
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable>]
|
||||
type ProfileSearch =
|
||||
{ /// Retrieve citizens from this continent
|
||||
continentId : string option
|
||||
/// Text for a search within a citizen's skills
|
||||
skill : string option
|
||||
/// Text for a search with a citizen's professional biography and experience fields
|
||||
bioExperience : string option
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
remoteWork : string
|
||||
}
|
||||
|
||||
|
||||
/// A user matching the profile search
|
||||
type ProfileSearchResult =
|
||||
{ /// The ID of the citizen
|
||||
citizenId : CitizenId
|
||||
/// The citizen's display name
|
||||
displayName : string
|
||||
/// Whether this citizen is currently seeking employment
|
||||
seekingEmployment : bool
|
||||
/// Whether this citizen is looking for remote work
|
||||
remoteWork : bool
|
||||
/// Whether this citizen is looking for full-time work
|
||||
fullTime : bool
|
||||
/// When this profile was last updated
|
||||
lastUpdatedOn : Instant
|
||||
}
|
||||
|
||||
|
||||
/// The data required to show a viewable profile
|
||||
type ProfileForView =
|
||||
{ /// The profile itself
|
||||
profile : Profile
|
||||
/// The citizen to whom the profile belongs
|
||||
citizen : Citizen
|
||||
/// The continent for the profile
|
||||
continent : Continent
|
||||
}
|
||||
|
||||
|
||||
/// The parameters for a public job search
|
||||
[<CLIMutable>]
|
||||
type PublicSearch =
|
||||
{ /// Retrieve citizens from this continent
|
||||
continentId : string option
|
||||
/// Retrieve citizens from this region
|
||||
region : string option
|
||||
/// Text for a search within a citizen's skills
|
||||
skill : string option
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
remoteWork : string
|
||||
}
|
||||
|
||||
/// Support functions for public searches
|
||||
module PublicSearch =
|
||||
/// Is the search empty?
|
||||
let isEmptySearch (search : PublicSearch) =
|
||||
[ search.continentId
|
||||
search.skill
|
||||
match search.remoteWork with "" -> Some search.remoteWork | _ -> None
|
||||
]
|
||||
|> List.exists Option.isSome
|
||||
|
||||
|
||||
/// A public profile search result
|
||||
type PublicSearchResult =
|
||||
{ /// The name of the continent on which the citizen resides
|
||||
continent : string
|
||||
/// The region in which the citizen resides
|
||||
region : string
|
||||
/// Whether this citizen is seeking remote work
|
||||
remoteWork : bool
|
||||
/// The skills this citizen has identified
|
||||
skills : string list
|
||||
}
|
||||
|
||||
|
||||
/// The data required to provide a success story
|
||||
type StoryForm =
|
||||
{ /// The ID of this story
|
||||
id : string
|
||||
/// Whether the employment was obtained from Jobs, Jobs, Jobs
|
||||
fromHere : bool
|
||||
/// The success story
|
||||
story : string
|
||||
}
|
||||
|
||||
|
||||
/// An entry in the list of success stories
|
||||
type StoryEntry =
|
||||
{ /// The ID of this success story
|
||||
id : SuccessId
|
||||
/// The ID of the citizen who recorded this story
|
||||
citizenId : CitizenId
|
||||
/// The name of the citizen who recorded this story
|
||||
citizenName : string
|
||||
/// When this story was recorded
|
||||
recordedOn : Instant
|
||||
/// Whether this story involves an opportunity that arose due to Jobs, Jobs, Jobs
|
||||
fromHere : bool
|
||||
/// Whether this report has a further story, or if it is simply a "found work" entry
|
||||
hasStory : bool
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/// Types within Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Domain.Types
|
||||
|
||||
open NodaTime
|
||||
open System
|
||||
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
|
||||
/// A user of Jobs, Jobs, Jobs
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Citizen =
|
||||
{ /// The ID of the user
|
||||
id : CitizenId
|
||||
/// The Mastodon instance abbreviation from which this citizen is authorized
|
||||
instance : string
|
||||
/// The handle by which the user is known on Mastodon
|
||||
mastodonUser : string
|
||||
/// The user's display name from Mastodon (updated every login)
|
||||
displayName : string option
|
||||
/// The user's real name
|
||||
realName : string option
|
||||
/// The URL for the user's Mastodon profile
|
||||
profileUrl : string
|
||||
/// When the user joined Jobs, Jobs, Jobs
|
||||
joinedOn : Instant
|
||||
/// When the user last logged in
|
||||
lastSeenOn : Instant
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
/// A continent
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Continent =
|
||||
{ /// The ID of the continent
|
||||
id : ContinentId
|
||||
/// The name of the continent
|
||||
name : string
|
||||
}
|
||||
|
||||
|
||||
/// A string of Markdown text
|
||||
type MarkdownString = Text of string
|
||||
|
||||
|
||||
/// The ID of a job listing
|
||||
type ListingId = ListingId of Guid
|
||||
|
||||
/// A job listing
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Listing =
|
||||
{ /// The ID of the job listing
|
||||
id : ListingId
|
||||
/// The ID of the citizen who posted the job listing
|
||||
citizenId : CitizenId
|
||||
/// When this job listing was created
|
||||
createdOn : Instant
|
||||
/// The short title of the job listing
|
||||
title : string
|
||||
/// The ID of the continent on which the job is located
|
||||
continentId : ContinentId
|
||||
/// The region in which the job is located
|
||||
region : string
|
||||
/// Whether this listing is for remote work
|
||||
remoteWork : bool
|
||||
/// Whether this listing has expired
|
||||
isExpired : bool
|
||||
/// When this listing was last updated
|
||||
updatedOn : Instant
|
||||
/// The details of this job
|
||||
text : MarkdownString
|
||||
/// When this job needs to be filled
|
||||
neededBy : LocalDate option
|
||||
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
||||
wasFilledHere : bool option
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a skill
|
||||
type SkillId = SkillId of Guid
|
||||
|
||||
/// A skill the job seeker possesses
|
||||
type Skill =
|
||||
{ /// The ID of the skill
|
||||
id : SkillId
|
||||
/// A description of the skill
|
||||
description : string
|
||||
/// Notes regarding this skill (level, duration, etc.)
|
||||
notes : string option
|
||||
}
|
||||
|
||||
|
||||
/// A job seeker profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Profile =
|
||||
{ /// The ID of the citizen to whom this profile belongs
|
||||
id : CitizenId
|
||||
/// Whether this citizen is actively seeking employment
|
||||
seekingEmployment : bool
|
||||
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
|
||||
isPublic : bool
|
||||
/// The ID of the continent on which the citizen resides
|
||||
continentId : ContinentId
|
||||
/// The region in which the citizen resides
|
||||
region : string
|
||||
/// Whether the citizen is looking for remote work
|
||||
remoteWork : bool
|
||||
/// Whether the citizen is looking for full-time work
|
||||
fullTime : bool
|
||||
/// The citizen's professional biography
|
||||
biography : MarkdownString
|
||||
/// When the citizen last updated their profile
|
||||
lastUpdatedOn : Instant
|
||||
/// The citizen's experience (topical / chronological)
|
||||
experience : MarkdownString option
|
||||
/// Skills this citizen possesses
|
||||
skills : Skill list
|
||||
}
|
||||
|
||||
/// The ID of a success report
|
||||
type SuccessId = SuccessId of Guid
|
||||
|
||||
/// A record of success finding employment
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Success =
|
||||
{ /// The ID of the success report
|
||||
id : SuccessId
|
||||
/// The ID of the citizen who wrote this success report
|
||||
citizenId : CitizenId
|
||||
/// When this success report was recorded
|
||||
recordedOn : Instant
|
||||
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
|
||||
fromHere : bool
|
||||
/// The source of this success (listing or profile)
|
||||
source : string
|
||||
/// The success story
|
||||
story : MarkdownString option
|
||||
}
|
||||
53
src/JobsJobsJobs/Home/Handlers.fs
Normal file
53
src/JobsJobsJobs/Home/Handlers.fs
Normal file
@@ -0,0 +1,53 @@
|
||||
/// Handlers for the home page, legal stuff, and help
|
||||
module JobsJobsJobs.Home.Handlers
|
||||
|
||||
open Giraffe
|
||||
open JobsJobsJobs.Common.Handlers
|
||||
|
||||
// GET: /
|
||||
let home : HttpHandler =
|
||||
renderHandler "Welcome" Views.home
|
||||
|
||||
// GET: /privacy-policy
|
||||
let privacyPolicy : HttpHandler =
|
||||
renderHandler "Privacy Policy" Views.privacyPolicy
|
||||
|
||||
// GET: /terms-of-service
|
||||
let termsOfService : HttpHandler =
|
||||
renderHandler "Terms of Service" Views.termsOfService
|
||||
|
||||
// GET: /how-it-works
|
||||
let howItWorks : HttpHandler =
|
||||
renderHandler "How It Works" Views.Help.index
|
||||
|
||||
// GET: /how-it-works/accounts
|
||||
let accountHelp : HttpHandler =
|
||||
renderHandler "Accounts | How It Works" Views.Help.accounts
|
||||
|
||||
// GET: /how-it-works/listings
|
||||
let listingHelp : HttpHandler =
|
||||
renderHandler "Job Listings | How It Works" Views.Help.listings
|
||||
|
||||
// GET: /how-it-works/profiles
|
||||
let profileHelp : HttpHandler =
|
||||
renderHandler "Employment Profiles | How It Works" Views.Help.profiles
|
||||
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All endpoints for this feature
|
||||
let endpoints =
|
||||
[ GET_HEAD [
|
||||
route "/" home
|
||||
route "/privacy-policy" privacyPolicy
|
||||
route "/terms-of-service" termsOfService
|
||||
]
|
||||
subRoute "/how-it-works" [
|
||||
GET_HEAD [
|
||||
route "" howItWorks
|
||||
route "/accounts" accountHelp
|
||||
route "/listings" listingHelp
|
||||
route "/profiles" profileHelp
|
||||
]
|
||||
]
|
||||
]
|
||||
16
src/JobsJobsJobs/Home/JobsJobsJobs.Home.fsproj
Normal file
16
src/JobsJobsJobs/Home/JobsJobsJobs.Home.fsproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
995
src/JobsJobsJobs/Home/Views.fs
Normal file
995
src/JobsJobsJobs/Home/Views.fs
Normal file
@@ -0,0 +1,995 @@
|
||||
module JobsJobsJobs.Home.Views
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open JobsJobsJobs.Common.Views
|
||||
|
||||
/// The home page
|
||||
let home =
|
||||
article [] [
|
||||
emptyP
|
||||
p [] [
|
||||
txt "Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one "
|
||||
txt "another in finding employment. This will enable them to continue providing value-for-value to Adam "
|
||||
txt "and John, as they continue their work deconstructing the misinformation that passes for news on a "
|
||||
txt "day-to-day basis."
|
||||
]
|
||||
p [] [
|
||||
txt "Do you not understand the terms in the paragraph above? No worries; just head over to "
|
||||
a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "The Best Podcast in the Universe"
|
||||
]
|
||||
txt " "; em [] [ audioClip "thats-true" (txt "(that’s true!)") ]
|
||||
txt " and find out what you’re missing."
|
||||
]
|
||||
]
|
||||
|
||||
/// The privacy policy
|
||||
let privacyPolicy =
|
||||
let appName = txt "Jobs, Jobs, Jobs"
|
||||
article [] [
|
||||
h3 [] [ txt "Privacy Policy" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of December 27<sup>th</sup>, 2022)" ]
|
||||
|
||||
p [] [
|
||||
appName; txt " (“we,” “our,” or “us”) is committed to protecting your "
|
||||
txt "privacy. This Privacy Policy explains how your personal information is collected, used, and disclosed "
|
||||
txt "disclosed by "; appName; txt "."
|
||||
]
|
||||
p [] [
|
||||
txt "This Privacy Policy applies to our website, and its associated subdomains (collectively, our "
|
||||
txt "“Service”) alongside our application, "; appName; rawText ". By accessing or using our "
|
||||
txt "Service, you signify that you have read, understood, and agree to our collection, storage, use, and "
|
||||
txt "disclosure of your personal information as described in this Privacy Policy and our Terms of Service."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Definitions and key terms" ]
|
||||
p [] [
|
||||
txt "To help explain things as clearly as possible in this Privacy Policy, every time any of these terms "
|
||||
txt "are referenced, are strictly defined as:"
|
||||
]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Cookie: small amount of data generated by a website and saved by your web browser. It is used to "
|
||||
txt "identify your browser, provide analytics, remember information about you such as your language "
|
||||
txt "preference or login information."
|
||||
]
|
||||
li [] [
|
||||
txt "Company: when this policy mentions “Company,” “we,” “us,” or "
|
||||
txt "“our,” it refers to "; appName; txt ", that is responsible for your information under "
|
||||
txt "this Privacy Policy."
|
||||
]
|
||||
li [] [
|
||||
txt "Country: where "; appName; txt " or the owners/founders of "; appName
|
||||
txt " are based, in this case is US."
|
||||
]
|
||||
li [] [
|
||||
txt "Customer: refers to the company, organization or person that signs up to use the "; appName
|
||||
txt " Service to manage the relationships with your consumers or service users."
|
||||
]
|
||||
li [] [
|
||||
txt "Device: any internet connected device such as a phone, tablet, computer or any other device that "
|
||||
txt "can be used to visit "; appName; txt " and use the services."
|
||||
]
|
||||
li [] [
|
||||
txt "IP address: Every device connected to the Internet is assigned a number known as an Internet "
|
||||
txt "protocol (IP) address. These numbers are usually assigned in geographic blocks. An IP address can "
|
||||
txt "often be used to identify the location from which a device is connecting to the Internet."
|
||||
]
|
||||
li [] [
|
||||
txt "Personnel: refers to those individuals who are employed by "; appName; txt " or are under "
|
||||
txt "contract to perform a service on behalf of one of the parties."
|
||||
]
|
||||
li [] [
|
||||
txt "Personal Data: any information that directly, indirectly, or in connection with other information "
|
||||
txt "— including a personal identification number — allows for the identification or identifiability "
|
||||
txt "of a natural person."
|
||||
]
|
||||
li [] [
|
||||
txt "Service: refers to the service provided by "; appName; txt " as described in the relative terms "
|
||||
txt "(if available) and on this platform."
|
||||
]
|
||||
li [] [
|
||||
txt "Third-party service: refers to advertisers, contest sponsors, promotional and marketing partners, "
|
||||
txt "and others who provide our content or whose products or services we think may interest you."
|
||||
]
|
||||
li [] [
|
||||
txt "Website: "; appName; txt "’s site, which can be accessed via this URL: "
|
||||
a [ _href "/" ] [ txt "https://noagendacareers.com/" ]
|
||||
]
|
||||
li [] [
|
||||
txt "You: a person or entity that is registered with "; appName; txt " to use the Services."
|
||||
]
|
||||
]
|
||||
|
||||
h4 [] [ txt "What Information Do We Collect?" ]
|
||||
p [] [
|
||||
txt "We collect information from you when you visit our website, register on our site, or fill out a form."
|
||||
]
|
||||
ul [] [
|
||||
li [] [ txt "Name / Username" ]
|
||||
li [] [ txt "Coarse Geographic Location" ]
|
||||
li [] [ txt "Employment History" ]
|
||||
li [] [ txt "Job Listing Information" ]
|
||||
]
|
||||
|
||||
h4 [] [ txt "How Do We Use The Information We Collect?" ]
|
||||
p [] [ txt "Any of the information we collect from you may be used in one of the following ways:" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "To personalize your experience (your information helps us to better respond to your individual "
|
||||
txt "needs)"
|
||||
]
|
||||
li [] [
|
||||
txt "To improve our website (we continually strive to improve our website offerings based on the "
|
||||
txt "information and feedback we receive from you)"
|
||||
]
|
||||
li [] [
|
||||
txt "To improve customer service (your information helps us to more effectively respond to your "
|
||||
txt "customer service requests and support needs)"
|
||||
]
|
||||
]
|
||||
|
||||
h4 [] [ txt "When does "; appName; txt " use end user information from third parties?" ]
|
||||
p [] [
|
||||
appName; txt " will collect End User Data necessary to provide the "; appName
|
||||
txt " services to our customers."
|
||||
]
|
||||
p [] [
|
||||
txt "End users may voluntarily provide us with information they have made available on social media "
|
||||
txt "websites. If you provide us with any such information, we may collect publicly available information "
|
||||
txt "from the social media websites you have indicated. You can control how much of your information "
|
||||
txt "social media websites make public by visiting these websites and changing your privacy settings."
|
||||
]
|
||||
|
||||
h4 [] [ txt "When does "; appName; txt " use customer information from third parties?" ]
|
||||
p [] [ txt "We do not utilize third party information apart from the end-user data described above." ]
|
||||
|
||||
h4 [] [ txt "Do we share the information we collect with third parties?" ]
|
||||
p [] [
|
||||
txt "We may disclose personal and non-personal information about you to government or law enforcement "
|
||||
txt "officials or private parties as we, in our sole discretion, believe necessary or appropriate in order "
|
||||
txt "to respond to claims, legal process (including subpoenas), to protect our rights and interests or "
|
||||
txt "those of a third party, the safety of the public or any person, to prevent or stop any illegal, "
|
||||
txt "unethical, or legally actionable activity, or to otherwise comply with applicable court orders, laws, "
|
||||
txt "rules and regulations."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Where and when is information collected from customers and end users?" ]
|
||||
p [] [
|
||||
appName; txt " will collect personal information that you submit to us. We may also receive personal "
|
||||
txt "information about you from third parties as described above."
|
||||
]
|
||||
|
||||
h4 [] [ txt "How Do We Use Your E-mail Address?" ]
|
||||
p [] [
|
||||
appName; txt " uses your e-mail address to identify you, along with your password, as an authorized user "
|
||||
txt "of this site. E-mail addresses are verified via a time-sensitive link, and may also be used to send "
|
||||
txt "password reset authorization codes. We do not display this e-mail address to users. If you choose to "
|
||||
txt "add an e-mail address as a contact type, that e-mail address will be visible to other authorized "
|
||||
txt "users."
|
||||
]
|
||||
|
||||
h4 [] [ txt "How Long Do We Keep Your Information?" ]
|
||||
p [] [
|
||||
txt "We keep your information only so long as we need it to provide "; appName; txt " to you and fulfill "
|
||||
txt "the purposes described in this policy. When we no longer need to use your information and there is no "
|
||||
txt "need for us to keep it to comply with our legal or regulatory obligations, we’ll either remove it "
|
||||
txt "from our systems or depersonalize it so that we can’t identify you."
|
||||
]
|
||||
|
||||
h4 [] [ txt "How Do We Protect Your Information?" ]
|
||||
p [] [
|
||||
txt "We implement a variety of security measures to maintain the safety of your personal information when "
|
||||
txt "you enter, submit, or access your personal information. We mandate the use of a secure server. We "
|
||||
txt "cannot, however, ensure or warrant the absolute security of any information you transmit to "; appName
|
||||
txt " or guarantee that your information on the Service may not be accessed, disclosed, altered, or "
|
||||
txt "destroyed by a breach of any of our physical, technical, or managerial safeguards."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Could my information be transferred to other countries?" ]
|
||||
p [] [
|
||||
appName; txt " is hosted in the US. Information collected via our website may be viewed and hosted "
|
||||
txt "anywhere in the world, including countries that may not have laws of general applicability regulating "
|
||||
txt "the use and transfer of such data. To the fullest extent allowed by applicable law, by using any of "
|
||||
txt "the above, you voluntarily consent to the trans-border transfer and hosting of such information."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Is the information collected through the "; appName; txt " Service secure?" ]
|
||||
p [] [
|
||||
txt "We take precautions to protect the security of your information. We have physical, electronic, and "
|
||||
txt "managerial procedures to help safeguard, prevent unauthorized access, maintain data security, and "
|
||||
txt "correctly use your information. However, neither people nor security systems are foolproof, including "
|
||||
txt "encryption systems. In addition, people can commit intentional crimes, make mistakes, or fail to "
|
||||
txt "follow policies. Therefore, while we use reasonable efforts to protect your personal information, we "
|
||||
txt "cannot guarantee its absolute security. If applicable law imposes any non-disclaimable duty to "
|
||||
txt "protect your personal information, you agree that intentional misconduct will be the standards used "
|
||||
txt "to measure our compliance with that duty."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Can I update or correct my information?" ]
|
||||
p [] [
|
||||
txt "The rights you have to request updates or corrections to the information "; appName
|
||||
txt " collects depend on your relationship with "; appName; txt "."
|
||||
]
|
||||
p [] [
|
||||
txt "Customers have the right to request the restriction of certain uses and disclosures of personally "
|
||||
txt "identifiable information as follows. You can contact us in order to (1) update or correct your "
|
||||
txt "personally identifiable information, or (3) delete the personally identifiable information maintained "
|
||||
txt "about you on our systems (subject to the following paragraph), by cancelling your account. Such "
|
||||
txt "updates, corrections, changes and deletions will have no effect on other information that we maintain "
|
||||
txt "in accordance with this Privacy Policy prior to such update, correction, change, or deletion. You are "
|
||||
txt "responsible for maintaining the secrecy of your unique password and account information at all times."
|
||||
]
|
||||
p [] [
|
||||
appName; txt " also provides ways for users to modify or remove the information we have collected from "
|
||||
txt "them from the application; these actions will have the same effect as contacting us to modify or "
|
||||
txt "remove data."
|
||||
]
|
||||
p [] [
|
||||
txt "You should be aware that it is not technologically possible to remove each and every record of the "
|
||||
txt "information you have provided to us from our system. The need to back up our systems to protect "
|
||||
txt "information from inadvertent loss means that a copy of your information may exist in a non-erasable "
|
||||
txt "form that will be difficult or impossible for us to locate. Promptly after receiving your request, "
|
||||
txt "all personal information stored in databases we actively use, and other readily searchable media will "
|
||||
txt "be updated, corrected, changed, or deleted, as appropriate, as soon as and to the extent reasonably "
|
||||
txt "and technically practicable."
|
||||
]
|
||||
p [] [
|
||||
txt "If you are an end user and wish to update, delete, or receive any information we have about you, you "
|
||||
txt "may do so by contacting the organization of which you are a customer."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Governing Law" ]
|
||||
p [] [
|
||||
txt "This Privacy Policy is governed by the laws of US without regard to its conflict of laws provision. "
|
||||
txt "You consent to the exclusive jurisdiction of the courts in connection with any action or dispute "
|
||||
txt "arising between the parties under or in connection with this Privacy Policy except for those "
|
||||
txt "individuals who may have rights to make claims under Privacy Shield, or the Swiss-US framework."
|
||||
]
|
||||
p [] [
|
||||
txt "The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of the "
|
||||
txt "website. Your use of the website may also be subject to other local, state, national, or "
|
||||
txt "international laws."
|
||||
]
|
||||
p [] [
|
||||
txt "By using "; appName; txt " or contacting us directly, you signify your acceptance of this Privacy "
|
||||
txt "Policy. If you do not agree to this Privacy Policy, you should not engage with our website, or use "
|
||||
txt "our services. Continued use of the website, direct engagement with us, or following the posting of "
|
||||
txt "changes to this Privacy Policy that do not significantly affect the use or disclosure of your "
|
||||
txt "personal information will mean that you accept those changes."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Your Consent" ]
|
||||
p [] [
|
||||
txt "We’ve updated our Privacy Policy to provide you with complete transparency into what is being "
|
||||
txt "set when you visit our site and how it’s being used. By using our website, registering an "
|
||||
txt "account, or making a purchase, you hereby consent to our Privacy Policy and agree to its terms."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Links to Other Websites" ]
|
||||
p [] [
|
||||
txt "This Privacy Policy applies only to the Services. The Services may contain links to other websites "
|
||||
txt "not operated or controlled by "; appName; txt ". We are not responsible for the content, accuracy, or "
|
||||
txt "opinions expressed in such websites, and such websites are not investigated, monitored, or checked "
|
||||
txt "for accuracy or completeness by us. Please remember that when you use a link to from the Services to "
|
||||
txt "another website, our Privacy Policy is no longer in effect. Your browsing and interaction on any "
|
||||
txt "other website, including those that have a link on our platform, is subject to that website’s own "
|
||||
txt "rules and policies. Such third parties may use their own cookies or other methods to collect "
|
||||
txt "information about you."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Cookies" ]
|
||||
p [] [
|
||||
appName; txt " uses a session Cookie to identify an active, logged-on session. This Cookie is removed when "
|
||||
txt "when You explicitly log off; is not accessible via script; and must be transferred over a secured, "
|
||||
txt "encrypted connection."
|
||||
]
|
||||
p [] [ appName; txt " uses no persistent or Third-Party Cookies." ]
|
||||
|
||||
h4 [] [ txt "Kids’ Privacy" ]
|
||||
p [] [
|
||||
txt "We do not address anyone under the age of 13. We do not knowingly collect personally identifiable "
|
||||
txt "information from anyone under the age of 13. If You are a parent or guardian and You are aware that "
|
||||
txt "Your child has provided Us with Personal Data, please contact Us. If We become aware that We have "
|
||||
txt "collected Personal Data from anyone under the age of 13 without verification of parental consent, We "
|
||||
txt "take steps to remove that information from Our servers."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Changes To Our Privacy Policy" ]
|
||||
p [] [
|
||||
txt "We may change our Service and policies, and we may need to make changes to this Privacy Policy so "
|
||||
txt "that they accurately reflect our Service and policies. Unless otherwise required by law, we will "
|
||||
txt "notify you (for example, through our Service) before we make changes to this Privacy Policy and give "
|
||||
txt "you an opportunity to review them before they go into effect. Then, if you continue to use the "
|
||||
txt "Service, you will be bound by the updated Privacy Policy. If you do not want to agree to this or any "
|
||||
txt "updated Privacy Policy, you can delete your account."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Third-Party Services" ]
|
||||
p [] [
|
||||
txt "We may display, include or make available third-party content (including data, information, "
|
||||
txt "applications and other products services) or provide links to third-party websites or services "
|
||||
txt "(“Third-Party Services”)."
|
||||
]
|
||||
p [] [
|
||||
txt "You acknowledge and agree that "; appName; txt " shall not be responsible for any Third-Party "
|
||||
txt "Services, including their accuracy, completeness, timeliness, validity, copyright compliance, "
|
||||
txt "legality, decency, quality or any other aspect thereof. "; appName; txt " does not assume and shall "
|
||||
txt "not have any liability or responsibility to you or any other person or entity for any Third-Party "
|
||||
txt "Services."
|
||||
]
|
||||
p [] [
|
||||
txt "Third-Party Services and links thereto are provided solely as a convenience to you and you access and "
|
||||
txt "use them entirely at your own risk and subject to such third parties’ terms and conditions."
|
||||
]
|
||||
|
||||
h4 [] [ txt "Tracking Technologies" ]
|
||||
p [] [ appName; txt " does not use any tracking technologies." ]
|
||||
|
||||
h4 [] [ txt "Information about General Data Protection Regulation (GDPR)" ]
|
||||
p [] [
|
||||
txt "We may be collecting and using information from you if you are from the European Economic Area (EEA), "
|
||||
txt "and in this section of our Privacy Policy we are going to explain exactly how and why is this data "
|
||||
txt "collected, and how we maintain this data under protection from being replicated or used in the wrong "
|
||||
txt "way."
|
||||
]
|
||||
|
||||
h5 [] [ txt "What is GDPR?" ]
|
||||
p [] [
|
||||
txt "GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is "
|
||||
txt "protected by companies and enhances the control the EU residents have, over their personal data."
|
||||
]
|
||||
p [] [
|
||||
txt "The GDPR is relevant to any globally operating company and not just the EU-based businesses and EU "
|
||||
txt "residents. Our customers’ data is important irrespective of where they are located, which is why we "
|
||||
txt "have implemented GDPR controls as our baseline standard for all our operations worldwide."
|
||||
]
|
||||
|
||||
h5 [] [ txt "What is personal data?" ]
|
||||
p [] [
|
||||
txt "Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum of "
|
||||
txt "information that could be used on its own, or in combination with other pieces of information, to "
|
||||
txt "identify a person. Personal data extends beyond a person’s name or email address. Some examples "
|
||||
txt "include financial information, political opinions, genetic data, biometric data, IP addresses, "
|
||||
txt "physical address, sexual orientation, and ethnicity."
|
||||
]
|
||||
p [] [ txt "The Data Protection Principles include requirements such as:" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Personal data collected must be processed in a fair, legal, and transparent way and should only "
|
||||
txt "be used in a way that a person would reasonably expect."
|
||||
]
|
||||
li [] [
|
||||
txt "Personal data should only be collected to fulfil a specific purpose and it should only be used "
|
||||
txt "for that purpose. Organizations must specify why they need the personal data when they collect it."
|
||||
]
|
||||
li [] [ txt "Personal data should be held no longer than necessary to fulfil its purpose." ]
|
||||
li [] [
|
||||
txt "People covered by the GDPR have the right to access their own personal data. They can also "
|
||||
txt "request a copy of their data, and that their data be updated, deleted, restricted, or moved to "
|
||||
txt "another organization."
|
||||
]
|
||||
]
|
||||
|
||||
h5 [] [ txt "Why is GDPR important?" ]
|
||||
p [] [
|
||||
txt "GDPR adds some new requirements regarding how companies should protect individuals’ personal "
|
||||
txt "data that they collect and process. It also raises the stakes for compliance by increasing "
|
||||
txt "enforcement and imposing greater fines for breach. Beyond these facts, it’s simply the right "
|
||||
txt "thing to do. At "; appName; txt " we strongly believe that your data privacy is very important and we "
|
||||
txt "already have solid security and privacy practices in place that go beyond the requirements of this "
|
||||
txt "regulation."
|
||||
]
|
||||
|
||||
h5 [] [ txt "Individual Data Subject’s Rights - Data Access, Portability, and Deletion" ]
|
||||
p [] [
|
||||
txt "We are committed to helping our customers meet the data subject rights requirements of GDPR. "
|
||||
appName; txt " processes or stores all personal data in fully vetted, DPA compliant vendors. We do store "
|
||||
txt "all conversation and personal data for up to 6 years unless your account is deleted. In which case, "
|
||||
txt "we dispose of all data in accordance with our Terms of Service and Privacy Policy, but we will not "
|
||||
txt "hold it longer than 60 days."
|
||||
]
|
||||
p [] [
|
||||
txt "We are aware that if you are working with EU customers, you need to be able to provide them with the "
|
||||
txt "ability to access, update, retrieve and remove personal data. We got you! We’ve been set up as "
|
||||
txt "self service from the start and have always given you access to your data. Our customer support team "
|
||||
txt "is here for you to answer any questions you might have about working with the API."
|
||||
]
|
||||
|
||||
h4 [] [ txt "California Residents" ]
|
||||
p [] [
|
||||
txt "The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal Information "
|
||||
txt "we collect and how we use it, the categories of sources from whom we collect Personal Information, "
|
||||
txt "and the third parties with whom we share it, which we have explained above."
|
||||
]
|
||||
p [] [
|
||||
txt "We are also required to communicate information about rights California residents have under "
|
||||
txt "California law. You may exercise the following rights:"
|
||||
]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Right to Know and Access. You may submit a verifiable request for information regarding the: (1) "
|
||||
txt "categories of Personal Information we collect, use, or share; (2) purposes for which categories "
|
||||
txt "of Personal Information are collected or used by us; (3) categories of sources from which we "
|
||||
txt "collect Personal Information; and (4) specific pieces of Personal Information we have collected "
|
||||
txt "about you."
|
||||
]
|
||||
li [] [
|
||||
txt "Right to Equal Service. We will not discriminate against you if you exercise your privacy rights."
|
||||
]
|
||||
li [] [
|
||||
txt "Right to Delete. You may submit a verifiable request to close your account and we will delete "
|
||||
txt "Personal Information about you that we have collected."
|
||||
]
|
||||
li [] [
|
||||
txt "Request that a business that sells a consumer’s personal data, not sell the "
|
||||
txt "consumer’s personal data."
|
||||
]
|
||||
]
|
||||
p [] [
|
||||
txt "If you make a request, we have one month to respond to you. If you would like to exercise any of "
|
||||
txt "these rights, please contact us."
|
||||
]
|
||||
p [] [ txt "We do not sell the Personal Information of our users." ]
|
||||
p [] [ txt "For more information about these rights, please contact us." ]
|
||||
|
||||
h5 [] [ txt "California Online Privacy Protection Act (CalOPPA)" ]
|
||||
p [] [
|
||||
txt "CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, the "
|
||||
txt "categories of sources from whom we collect Personal Information, and the third parties with whom we "
|
||||
txt "share it, which we have explained above."
|
||||
]
|
||||
p [] [ txt "CalOPPA users have the following rights:" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Right to Know and Access. You may submit a verifiable request for information regarding the: (1) "
|
||||
txt "categories of Personal Information we collect, use, or share; (2) purposes for which categories "
|
||||
txt "of Personal Information are collected or used by us; (3) categories of sources from which we "
|
||||
txt "collect Personal Information; and (4) specific pieces of Personal Information we have collected "
|
||||
txt "about you."
|
||||
]
|
||||
li [] [
|
||||
txt "Right to Equal Service. We will not discriminate against you if you exercise your privacy rights."
|
||||
]
|
||||
li [] [
|
||||
txt "Right to Delete. You may submit a verifiable request to close your account and we will delete "
|
||||
txt "Personal Information about you that we have collected."
|
||||
]
|
||||
li [] [
|
||||
txt "Right to request that a business that sells a consumer’s personal data, not sell the "
|
||||
txt "consumer’s personal data."
|
||||
]
|
||||
]
|
||||
p [] [
|
||||
txt "If you make a request, we have one month to respond to you. If you would like to exercise any of "
|
||||
txt "these rights, please contact us."
|
||||
]
|
||||
p [] [ txt "We do not sell the Personal Information of our users." ]
|
||||
p [] [ txt "For more information about these rights, please contact us." ]
|
||||
|
||||
h4 [] [ txt "Contact Us" ]
|
||||
p [] [ txt "Don’t hesitate to contact us if you have any questions." ]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Via this Link: "; a [ _href "/how-it-works" ] [ txt "https://noagendacareers.com/how-it-works" ]
|
||||
]
|
||||
]
|
||||
|
||||
hr []
|
||||
|
||||
p [ _class "fst-italic" ] [ txt "Changes for "; appName; txt " v3 (December 27<sup>th</sup>, 2022)" ]
|
||||
ul [] [
|
||||
li [ _class "fst-italic" ] [ txt "Removed references to Mastodon" ]
|
||||
li [ _class "fst-italic" ] [ txt "Added references to job listings" ]
|
||||
li [ _class "fst-italic" ] [ txt "Changed information regarding e-mail addresses" ]
|
||||
li [ _class "fst-italic" ] [ txt "Updated cookie / tracking sections for new architecture" ]
|
||||
]
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Change on September 6<sup>th</sup>, 2021 – replaced “No Agenda Social” with generic "
|
||||
txt "terms for any authorized Mastodon instance."
|
||||
]
|
||||
]
|
||||
|
||||
/// The page for terms of service
|
||||
let termsOfService =
|
||||
article [] [
|
||||
h3 [] [ txt "Terms of Service" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of August 30<sup>th</sup>, 2022)" ]
|
||||
h4 [] [ txt "Acceptance of Terms" ]
|
||||
p [] [
|
||||
txt "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
|
||||
txt "are responsible to ensure that your use of this site complies with all applicable laws. Your "
|
||||
txt "continued use of this site implies your acceptance of these terms."
|
||||
]
|
||||
h4 [] [ txt "Description of Service and Registration" ]
|
||||
p [] [
|
||||
txt "Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles and job "
|
||||
txt "listings, restricting access to the details of these to other users of this site, unless the "
|
||||
txt "individual specifies that this information should be visible publicly. See our "
|
||||
a [ _href "/privacy-policy" ] [ txt "privacy policy" ]
|
||||
txt " for details on the personal (user) information we maintain."
|
||||
]
|
||||
h4 [] [ txt "Liability" ]
|
||||
p [] [
|
||||
txt "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
||||
txt "service and its developers may not be held liable for any damages that may arise through the use of "
|
||||
txt "this service."
|
||||
]
|
||||
h4 [] [ txt "Updates to Terms" ]
|
||||
p [] [
|
||||
txt "These terms and conditions may be updated at any time. When these terms are updated, users will be "
|
||||
txt "notified via a notice on the dashboard page. Additionally, the date at the top of this page will be "
|
||||
txt "updated, and any substantive updates will also be accompanied by a summary of those changes."
|
||||
]
|
||||
hr []
|
||||
p [] [
|
||||
txt "You may also wish to review our "; a [ _href "/privacy-policy" ] [ txt "privacy policy" ]
|
||||
txt " to learn how we handle your data."
|
||||
]
|
||||
hr []
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Change on August 30<sup>th</sup>, 2022 – added references to job listings, removed references "
|
||||
txt "to Mastodon instances."
|
||||
]
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Change on September 6<sup>th</sup>, 2021 – replaced “No Agenda Social” with a list "
|
||||
txt "of all No Agenda-affiliated Mastodon instances."
|
||||
]
|
||||
]
|
||||
|
||||
/// Help page templates
|
||||
module Help =
|
||||
|
||||
/// CSS classes to indicate a reference to a particular page within Jobs, Jobs, Jobs
|
||||
let private linkedPage = _class "badge text-light bg-success"
|
||||
|
||||
/// CSS classes to indicate a reference to a named button
|
||||
let private actionButton = _class "badge text-light bg-secondary rounded-pill"
|
||||
|
||||
/// CSS classes for main headings within the documentation
|
||||
let private mainHeading = _class "border-top border-2 mt-4 pt-1"
|
||||
|
||||
/// A link to return to the help index
|
||||
let private backToIndex =
|
||||
p [] [ a [ _href "/how-it-works" ] [ txt "« Back to Help Index" ] ]
|
||||
|
||||
/// Account help page
|
||||
let accounts =
|
||||
[ backToIndex
|
||||
p [] [
|
||||
span [ _class "fst-italic" ] [
|
||||
txt "On this page: "; a [ _href "#registration" ] [ txt "Registration" ]; txt " • "
|
||||
a [ _href "#log-on" ] [ txt "Logging On" ]; txt " • "
|
||||
a [ _href "#updating" ] [ txt "Updating Your Account" ]; txt " • "
|
||||
a [ _href "#deleting" ] [ txt "Deleting Your Account" ]
|
||||
]
|
||||
]
|
||||
|
||||
h4 [ _id "registration"; mainHeading ] [ txt "Registering for an Account" ]
|
||||
p [] [
|
||||
txt "To register for an account, go to the "; span [ linkedPage ] [ txt "Log On" ]; txt " page; just "
|
||||
txt "below the "; span [ actionButton ] [ txt "Log On" ]; txt " button, there is a link that leads to "
|
||||
txt "the registration page. The first three fields are name fields, the fourth is an e-mail address, "
|
||||
txt "and the final two are for your password. Your password must be at least 8 characters long."
|
||||
]
|
||||
p [] [
|
||||
txt "As Jobs, Jobs, Jobs is targeted towards "
|
||||
a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ]
|
||||
txt " listeners, there are also two questions that test your knowledge of the show. The answers to "
|
||||
txt "these questions are not case-sensitive, and should pose no significant challenge to those who "
|
||||
txt "have listened to an episode or two "; em [] [ txt "(including the donation segments!)" ]; txt "."
|
||||
]
|
||||
p [] [
|
||||
txt "Once you click the "; span [ actionButton ] [ txt "Register" ]; txt " button, the system will "
|
||||
txt "send an e-mail to you with a confirmation link. This link must be visited within 72 hours, or the "
|
||||
txt "account request will be deleted. Once the account is confirmed, though, you are ready to…"
|
||||
]
|
||||
|
||||
h4 [ _id "log-on"; mainHeading ] [ txt "Log On" ]
|
||||
p [] [
|
||||
txt "Logging on to Jobs, Jobs, Jobs is a straightforward process; enter your e-mail address and "
|
||||
txt "password. Beside visiting the page directly, you may also be directed to it if you click a link "
|
||||
txt "that requires you to be logged on to view. In these cases, instead of being directed to the "
|
||||
txt "dashboard page, you would instead be directed back to the page you were trying to view."
|
||||
]
|
||||
|
||||
h4 [ _id "updating"; mainHeading ] [ txt "Updating Your Account and Contact Information" ]
|
||||
p [] [
|
||||
txt "When you are logged on, clicking "; span [ linkedPage ] [ txt "My Account" ]; txt " in the menu "
|
||||
txt "will take you to the Account Profile page. This page allows you to change any of the three name "
|
||||
txt "fields. You can also change your password from this page; if you do not want to change it, "
|
||||
txt "though, leave both of those fields blank."
|
||||
]
|
||||
p [] [
|
||||
txt "This page is also where you can enter contact information. Clicking the "
|
||||
span [ actionButton ] [ txt "Add a Contact Method" ]; txt " button will add a row to the table, and in "
|
||||
txt "each row, clicking the "; span [ actionButton ] [ txt "−" ]; txt " button will remove that "
|
||||
txt "row. When others view your employment profile, or any job listings you may create, this contact "
|
||||
txt "information is displayed along with it. Each contact can be marked as “Public”; if "
|
||||
txt "you opt to allow public access to your employment profile, the contacts marked as public will be "
|
||||
txt "displayed, but the others will be excluded."
|
||||
]
|
||||
p [] [
|
||||
txt "For each contact, you may specify a name. For websites and e-mail addresses, this name will be "
|
||||
txt "the text of the link generated for that item; otherwise, the links will be “Website” "
|
||||
txt "and “E-mail”, respectively. For phone numbers, the name will appear in parentheses "
|
||||
txt "after the number."
|
||||
]
|
||||
|
||||
h4 [ _id "deleting"; mainHeading ] [ txt "Deleting Your Account" ]
|
||||
p [] [
|
||||
txt "Though we hate to see it, we realize that there may come a time when you want to delete either "
|
||||
txt "your employment profile or your complete account. On both the "
|
||||
span [ linkedPage ] [ txt "Account Update" ]; txt " and "; span [ linkedPage ] [ txt "Profile Edit" ]
|
||||
txt " pages, there is a link to a page with deletion options."
|
||||
]
|
||||
p [] [
|
||||
txt "The first red button will delete your employment profile. This means that no searches for "
|
||||
txt "employment profiles will return your information. If you had shared a direct link to your "
|
||||
txt "profile, it will return a “Not Found” page – unless you take steps to create "
|
||||
txt "your employment profile again, in which case the same link will work again. If you want to "
|
||||
txt "obtain the same effect without deleting your profile, update it and select the "
|
||||
txt "“Invisible” visibility option."
|
||||
]
|
||||
p [] [
|
||||
txt "The second red button will delete your entire account. In addition to your employment profile "
|
||||
txt "being deleted, it will delete any job listings you may have posted, any success stories you may "
|
||||
txt "have recorded, and your account information. It will make it like you were never a user of this "
|
||||
txt "system. If you re-register, you will be given a different ID, so direct links to your previous "
|
||||
txt "employment profile will not work, even if you create an employment profile for your new account."
|
||||
]
|
||||
p [] [
|
||||
strong [] [ txt "Use both these options with extreme care!" ]; txt " There is no “undo” "
|
||||
txt "for these actions; if you change your mind, you will have to recreate what you have deleted."
|
||||
]
|
||||
]
|
||||
|> pageWithTitle "How It Works: Accounts"
|
||||
|
||||
/// Job listings help page
|
||||
let listings =
|
||||
[ backToIndex
|
||||
p [] [
|
||||
span [ _class "fst-italic" ] [
|
||||
txt "On this page: "
|
||||
a [ _href "#creating" ] [ txt "Creating a Job Listing" ]; txt " • "
|
||||
a [ _href "#maintaining" ] [ txt "Maintaining" ]; txt " • "
|
||||
a [ _href "#searching" ] [ txt "Searching" ]
|
||||
]
|
||||
]
|
||||
|
||||
h4 [ _id "creating"; mainHeading ] [ txt "Creating a Job Listing" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage] [ txt "My Job Listings" ]; txt " page shows all of the job listings you "
|
||||
txt "have created. To add a new one, click the "; span [ actionButton] [ txt "Add a New Listing" ]
|
||||
txt " button. This page allows you to specify a title for the listing; the continent and region; "
|
||||
txt "whether it is a remote opportunity; the date by which a job needs to be filled; and a full "
|
||||
txt "description of the position, using "; a [ _href "/how-it-works#markdown" ] [ txt "Markdown" ]
|
||||
txt ". Once you save the listing, it will be visible to the other citizens here."
|
||||
]
|
||||
|
||||
h4 [ _id "maintaining"; mainHeading ] [ txt "Maintaining and Sharing Your Job Listings" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage] [ txt "My Job Listings" ]; txt " page will show you all of your active "
|
||||
txt "job listings just below the "; span [ actionButton] [ txt "Add a Job Listing" ]; txt " button. "
|
||||
txt "Within this table, you can edit the listing, view it, or expire it (more on that below). The "
|
||||
span [ linkedPage] [ txt "View" ]; txt " link will show you the job listing just as other users will "
|
||||
txt "see it. You can share the link from your browser, and other Jobs, Jobs, Jobs users will be able "
|
||||
txt "to log on and view it."
|
||||
]
|
||||
|
||||
h5 [] [ txt "Expire a Job Listing" ]
|
||||
p [] [
|
||||
txt "Once the job is filled, or the opportunity has passed, you will want to expire the listing; this "
|
||||
txt "is what the "; span [ linkedPage] [ txt "Expire" ]; txt " link allows you to do. When you click "
|
||||
txt "it, you will be presented with a single question – was the job filled due to its listing "
|
||||
txt "here? If not, leave that blank, click the "; span [ actionButton] [ txt "Expire" ]; txt " button, "
|
||||
txt "and the listing will be expired. If you click that box, though, another Markdown editor will "
|
||||
txt "appear, where you can share a story of the experience. This is not required, but if you put text "
|
||||
txt "there, it will be recorded as a Success Story, and other users will be able to read about your "
|
||||
txt "success."
|
||||
]
|
||||
p [] [
|
||||
txt "Once you have at least one expired job listing, the "; span [ linkedPage] [ txt "My Job Listing" ]
|
||||
txt " page will have a new section below your active listings, where you can see your expired ones. "
|
||||
txt "You can still view the expired listing, and links that you may have shared will still pull up the "
|
||||
txt "listing; there will be an “expired” label beside the title, so that whoever is "
|
||||
txt "viewing it knows that they are reading about a job that is no longer available."
|
||||
]
|
||||
|
||||
h4 [ _id "searching"; mainHeading] [ txt "Searching Job Listings" ]
|
||||
p [] [
|
||||
txt "Active job listings are found on the "; span [ linkedPage ] [ txt "Help Wanted!" ]; txt " page. "
|
||||
txt "When you first bring up this page, you will see several criteria by which you can narrow your "
|
||||
txt "results, though none are required. When you click the "; span [ actionButton ] [ txt "Search" ]
|
||||
txt " button, you will see open job listings filtered by whatever criteria you specified. Each job "
|
||||
txt "displays its title, its location, whether it is a remote opportunity, and (if specified) the date "
|
||||
txt "by which the job needs to be filled."
|
||||
]
|
||||
p [] [
|
||||
txt "Clicking the "; span [ linkedPage] [ txt "View" ]; txt " link on a listing brings up the full "
|
||||
txt "view page for a listing. This page displays all of the information from the search results, along "
|
||||
txt "with the citizen who posted it, and the full details of the job. All the citizen’s contact "
|
||||
txt "information is displayed, and you can use any of these means to get in touch with them to inquire "
|
||||
txt "about the position."
|
||||
]
|
||||
]
|
||||
|> pageWithTitle "How It Works: Job Listings"
|
||||
|
||||
/// Profile help page
|
||||
let profiles =
|
||||
[ backToIndex
|
||||
p [] [
|
||||
span [ _class "fst-italic" ] [
|
||||
txt "On this page: "
|
||||
a [ _href "#creating" ] [ txt "Creating and Editing Your Employment Profile" ]
|
||||
txt " • "; a [ _href "#searching" ] [ txt "Searching" ]; txt " • "
|
||||
a [ _href "#viewing" ] [ txt "Viewing, Printing, and Sharing" ]; txt " • "
|
||||
a [ _href "#finding" ] [ txt "Finding Employment" ]
|
||||
]
|
||||
]
|
||||
|
||||
h4 [ _id "creating"; mainHeading ] [ txt "Creating and Editing Your Employment Profile" ]
|
||||
p [] [
|
||||
txt "The employment profile is your résumé, visible to other citizens here. On your "
|
||||
txt "dashboard, there we be either a "; span [ actionButton ] [ txt "Create Profile" ]; txt " or an "
|
||||
span [ actionButton ] [ txt "Edit Profile" ]; txt " button, depending on whether you have a profile "
|
||||
txt "established yet. There is also a "; span [ linkedPage ] [ txt "My Employment Profile" ]
|
||||
txt " entry in the menu when you are logged on."
|
||||
]
|
||||
p [] [
|
||||
txt "The employment profile itself is divided into three sections, and the main edit page serves as a "
|
||||
txt "menu for them. If you have a profile established, there is a button labeled "
|
||||
span [ actionButton ] [ txt "View Your Employment Profile" ]; txt " which will show you your profile "
|
||||
txt "the way other users will see it."
|
||||
]
|
||||
|
||||
h5 [] [ txt "General Information" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage ] [ txt "General Information" ]; txt " section of the Employment Profile "
|
||||
txt "contains the following fields:"
|
||||
]
|
||||
ul [] [
|
||||
li [] [
|
||||
strong [] [ txt "Continent" ]; txt " (required): The continent on which you most frequently reside"
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Region" ]; txt " (required): A smaller geographic area within the continent you "
|
||||
txt "selected. This is free-form text; describe it as narrowly or broadly as you like."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Three Yes/No Questions" ]; txt ": If you are actively seeking work, check the "
|
||||
txt "“I am currently seeking employment” box; if you are interested in full-time or "
|
||||
txt "remote work, check those boxes. Leaving them unchecked implies “No”."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Professional Biography" ]; txt " (required): Your description of what type of job "
|
||||
txt "you are seeking, an overview of your professional history, etc. This is the main "
|
||||
txt "résumé portion of the profile. It supports "
|
||||
a [ _href "/how-it-works#markdown" ] [ txt "Markdown" ]; txt " formatting, which allows you to add "
|
||||
txt "headings, bold/emphasized text, links, etc."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Experience" ]; txt ": This Markdown block will be placed at the bottom of the "
|
||||
txt "employment profile, below your skills and employment history. It is optional (as are skills "
|
||||
txt "and employment history). This area can be omitted; used to display skills or employment "
|
||||
txt "history in a different way than this application provides; or used to provide a closing "
|
||||
txt "appeal, the text “References available upon request”, or any other information "
|
||||
txt "you want displayed at the bottom of your profile."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Visibility" ]; txt " (required):"
|
||||
ul [] [
|
||||
li [] [
|
||||
em [] [ txt "Hidden" ]; txt " will restrict your profile to yourself only; this "
|
||||
txt "can be used while you are initially building your profile, or to remove your profile "
|
||||
txt "from search results without deleting it."
|
||||
]
|
||||
li [] [
|
||||
em [] [ txt "Private" ]; txt " (the default) will restrict your profile to logged-on users "
|
||||
txt "of this community."
|
||||
]
|
||||
li [] [
|
||||
em [] [ txt "Anonymous"]; txt " will allow your continent, region, and skills to be "
|
||||
txt "displayed in public searches, without linking to your profile or disclosing any "
|
||||
txt "additional information."
|
||||
]
|
||||
li [] [
|
||||
em [] [ txt "Public" ]; txt " will allow public users to search, view, and print your "
|
||||
txt "profile, just as you or logged-on users can. It also allows you to share the link to "
|
||||
txt "your employment profile with the general public."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
h5 [] [ txt "Skills" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage ] [ txt "Skills" ]; txt " section allows you to record skills you have "
|
||||
txt "obtained. Each skill has an associated “Notes” field, which can be used to document "
|
||||
txt "your level of proficiency, years practiced, etc. Skills will be sorted alphabetically, and "
|
||||
txt "displayed in the profile just below the “Professional Biography”."
|
||||
]
|
||||
p [] [
|
||||
txt "Note that if you select “Anonymous” visibility, what you enter in these fields will "
|
||||
txt "visible in public search results. In this case, be sure to omit any identifying information from "
|
||||
txt "either field."
|
||||
]
|
||||
|
||||
h5 [] [ txt "Employment History" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage ] [ txt "Employment History" ]; txt " section allows you to record a "
|
||||
txt "chronological listing of your previous or current employment. This section is sorted descendingly "
|
||||
txt "(most recent first) by end date, with ongoing jobs listed first. If any entries are present, "
|
||||
txt "they will be displayed below the list of skills (or below the “Professional "
|
||||
txt "Biography”, if no skills are present)."
|
||||
]
|
||||
p [] [
|
||||
txt "Each employment history entry consists of the following fields:"
|
||||
]
|
||||
ul [] [
|
||||
li [] [
|
||||
strong [] [ txt "Employer" ]; txt " (required): The name of the entity by which you were employed"
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Title or Position" ]; txt " (required): The title or job position you held with "
|
||||
txt "the employer, usually the most recent or senior"
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Start Date" ]; txt " (required): The date you began this period of employment. "
|
||||
txt "While you will see a full calendar, only the month and year will be displayed on the "
|
||||
txt "employment profile, so you may select any day within the month."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "End Date" ]; txt ": The date you ended this period of employment. As with the "
|
||||
txt "“Start Date” field, it will only display month and year. Also, if you still have "
|
||||
txt "this job, leave this blank, and “to Present” will be displayed for the end date."
|
||||
]
|
||||
li [] [
|
||||
strong [] [ txt "Description" ]; txt ": An optional description of your employment. This field "
|
||||
txt "supports Markdown, and will appear below the employer, position, and dates if present."
|
||||
]
|
||||
]
|
||||
|
||||
h4 [ _id "searching"; mainHeading ] [ txt "Searching Employment Profiles" ]
|
||||
|
||||
h5 [] [ txt "Search Criteria" ]
|
||||
p [] [
|
||||
txt "The "; span [ linkedPage ] [ txt "Job Seekers" ]; txt " page and its search form is the same "
|
||||
txt "whether there is a user logged on or not; however, the results are different. There are three "
|
||||
txt "sections to the search form. "; strong [] [ txt "Continent" ]; txt " will select profiles from "
|
||||
txt "the selected continent, while "; strong [] [ txt "Seeking Remote Work?" ]; txt " will select "
|
||||
txt "profiles based whether the citizen has selected remote work in their profile. "
|
||||
strong [] [ txt "Text Search" ]; txt " will search several aspects of the employment profile for "
|
||||
txt "matches; it is case-insensitive and will match using English stemming rules (ex. searching for "
|
||||
txt "“force” will match words like “force”, “forced”, or "
|
||||
txt "“forcing”)."
|
||||
]
|
||||
p [] [
|
||||
txt "If more than one field has a value selected, profiles must match all of those selections to be "
|
||||
txt "returned. Additionally, “Hidden” profiles will never be returned in searches, and "
|
||||
txt "if no user is logged on, only “Public” and “Anonymous” profiles will be "
|
||||
txt "considered as candidates for search results."
|
||||
]
|
||||
|
||||
h5 [] [ txt "Search Results" ]
|
||||
p [] [
|
||||
txt "If the visibility of the profile allows you to view it, the first column will contain a "
|
||||
span [ linkedPage ] [ txt "View" ]; txt " link. The remaining columns will display other attributes "
|
||||
txt "of the employment profile. In public search results for profiles with “Anonymous” "
|
||||
txt "visibility, the last column of the search results will show the citizen’s skills."
|
||||
]
|
||||
|
||||
h4 [ _id "viewing"; mainHeading ] [ txt "Viewing, Printing, and Sharing Employment Profiles" ]
|
||||
p [] [
|
||||
txt "An employment profile can be viewed via the "; span [ linkedPage ] [ txt "View" ]; txt " link in "
|
||||
txt "search results, and logged-on users can find this view via the "
|
||||
span [ actionButton ] [ txt "View Your Employment Profile" ]; txt " button on the "
|
||||
span [ linkedPage ] [ txt "My Employment Profile" ]; txt " page. The profile view page displays the "
|
||||
txt "following information in order:"
|
||||
]
|
||||
ul [] [
|
||||
li [] [
|
||||
txt "Display Name "; em [] [ txt "(with a tag if the citizen is actively seeking employment)" ]
|
||||
]
|
||||
li [] [ txt "Continent / Region" ]
|
||||
li [] [ txt "Contact Information "; em [] [ txt "(only public items if viewing publicly)" ] ]
|
||||
li [] [ txt "Remote / Full-Time selections" ]
|
||||
li [] [ txt "Professional Biography" ]
|
||||
li [] [ txt "Skill list" ]
|
||||
li [] [ txt "Employment History" ]
|
||||
li [] [ txt "Experience" ]
|
||||
]
|
||||
p [] [
|
||||
txt "If a user is logged on and is viewing their own profile, there will be an "
|
||||
span [ actionButton ] [ txt "Edit Your Profile" ]; txt " button at the bottom of the page. Either way, "
|
||||
txt "there will also be a "; span [ actionButton ] [ txt "View Print Version" ]; txt " button that "
|
||||
txt "will open a printable version in a new browser tab. This version is constructed the same way as "
|
||||
txt "the previous page; however, contact information will be fully displayed rather than linked. There "
|
||||
txt "will be no menu, header, or footer on the page, but there will be a "
|
||||
span [ actionButton ] [ txt "Print" ]; txt " button at the bottom of the page, which will not show on "
|
||||
txt "the actual printed version."
|
||||
]
|
||||
p [] [
|
||||
txt "Both the view and print versions may be shared by copying the full URL from the browser’s "
|
||||
txt "address bar. When using this URL, “Public” visibility profiles will display for "
|
||||
txt "anyone, while “Anonymous” and “Private” visibility profiles will require "
|
||||
txt "a citizen to log on first. “Hidden” visibility profiles will return a “page not "
|
||||
txt "found” error for anyone other than the citizen to which they belong."
|
||||
]
|
||||
|
||||
h4 [ _id "finding"; mainHeading ] [ txt "Finding Employment" ]
|
||||
p [] [
|
||||
txt "If your profile indicates that you are seeking employment, and you secure employment, you will "
|
||||
txt "want to update that (and – congratulations!). From both the "
|
||||
span [ linkedPage ] [ txt "Dashboard" ]; txt " and the "
|
||||
span [ linkedPage ] [ txt "General Information" ]; txt " Employment Profile pages, you will see a link "
|
||||
txt "that encourages you to tell us about it. Click either of those links, and you will be brought to "
|
||||
txt "a page that allows you to indicate whether your employment actually came from hosting your "
|
||||
txt "employment profile on Jobs, Jobs, Jobs, and gives you a place to write about the experience. "
|
||||
txt "These stories are only viewable by logged-on citizens, so feel free to use as much (or as little) "
|
||||
txt "identifying information as you’d like. You can also submit this page with all the fields "
|
||||
txt "blank; in that case, your “Seeking Employment” flag is cleared, and the blank story "
|
||||
txt "is recorded."
|
||||
]
|
||||
p [] [
|
||||
txt "As a validated user, you can also view others’ success stories from both their employment "
|
||||
txt "profiles or job listings. Clicking "; span [ linkedPage ] [ txt "Success Stories" ]; txt " in the "
|
||||
txt "sidebar will display a list of all the stories that have been recorded. If there is a story to be "
|
||||
txt "read, there will be a link to read it; if you submitted the story, there will also be an "
|
||||
span [ linkedPage ] [ txt "Edit" ]; txt " link."
|
||||
]
|
||||
]
|
||||
|> pageWithTitle "How It Works: Employment Profiles"
|
||||
|
||||
/// Online help / documentation index page
|
||||
let index =
|
||||
article [] [
|
||||
h3 [ _class "mb-0" ] [ txt "How It Works" ]
|
||||
h6 [ _class "mb-3 text-muted fst-italic" ] [ txt "Last Updated January 22<sup>nd</sup>, 2023" ]
|
||||
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Show me how to "; a [ _href "/how-it-works/listings#searching" ] [ txt "find a job" ]
|
||||
txt " • "; a [ _href "/how-it-works/listings#creating" ] [ txt "list a job opportunity" ]
|
||||
txt " • "; a [ _href "/how-it-works/profiles#searching" ] [ txt "find people to hire" ]
|
||||
txt " • "; a [ _href "/how-it-works/profiles#creating" ] [ txt "create an employment profile" ]
|
||||
]
|
||||
|
||||
p [] [
|
||||
strong [] [ a [ _href "/how-it-works/accounts" ] [ txt "Accounts" ] ]; br []
|
||||
txt "How to sign up as a user, get logged on, update your information, or remove your account"
|
||||
]
|
||||
p [] [
|
||||
strong [] [ a [ _href "/how-it-works/listings" ] [ txt "Job Listings" ] ]; br []
|
||||
txt "Creating, maintaining, and sharing job opportunities with fellow citizens"
|
||||
]
|
||||
p [] [
|
||||
strong [] [ a [ _href "/how-it-works/profiles" ] [ txt "Employment Profiles" ] ]; br []
|
||||
txt "Creating, updating, and sharing your own employment profile; and searching, viewing, and printing "
|
||||
txt "profiles for yourself or your fellow citizens"
|
||||
]
|
||||
|
||||
h4 [ _id "markdown"; mainHeading ] [ txt "A Bit about Markdown" ]
|
||||
p [] [
|
||||
txt "Markdown is a plain-text way to specify formatting quite similar to that provided by word processors. "
|
||||
txt "The "
|
||||
a [ _href "https://daringfireball.net/projects/markdown/"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "original page"
|
||||
]; txt " for the project is a good overview of its capabilities, and the pages at "
|
||||
a [ _href "https://www.markdownguide.org/"; _target "_blank"; _rel "noopener" ] [ txt "Markdown Guide" ]
|
||||
txt " give in-depth lessons to make the most of this language. The version of Markdown employed here "
|
||||
txt "supports many popular extensions, include smart quotes (turning "a quote" into “a "
|
||||
txt "quote”), tables, super/subscripts, and more."
|
||||
]
|
||||
|
||||
h4 [ mainHeading ] [ txt "Help / Suggestions" ]
|
||||
p [] [
|
||||
txt "This is open-source software "
|
||||
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "developed on Github"
|
||||
]; txt "; feel free to "
|
||||
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs/issues"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "create an issue there"
|
||||
]; txt ", or look up @danieljsummers on No Agenda Social."
|
||||
]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.fs" />
|
||||
<Content Include="appsettings.Migration.json" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\JobsJobsJobs.Application.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
217
src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs
Normal file
217
src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs
Normal file
@@ -0,0 +1,217 @@
|
||||
|
||||
open System.Text.Json
|
||||
open Microsoft.Extensions.Configuration
|
||||
|
||||
/// Data access for v2 Jobs, Jobs, Jobs
|
||||
module Rethink =
|
||||
|
||||
/// Table names
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
/// The user (citizen of Gitmo Nation) table
|
||||
let Citizen = "citizen"
|
||||
/// The continent table
|
||||
let Continent = "continent"
|
||||
/// The job listing table
|
||||
let Listing = "listing"
|
||||
/// The citizen employment profile table
|
||||
let Profile = "profile"
|
||||
/// The success story table
|
||||
let Success = "success"
|
||||
/// All tables
|
||||
let all () = [ Citizen; Continent; Listing; Profile; Success ]
|
||||
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Functions run at startup
|
||||
[<RequireQualifiedAccess>]
|
||||
module Startup =
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.JsonNet
|
||||
open RethinkDb.Driver.FSharp
|
||||
|
||||
/// Create a RethinkDB connection
|
||||
let createConnection (connStr : string) =
|
||||
// Add all required JSON converters
|
||||
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
||||
// Connect to the database
|
||||
let config = DataConfig.FromUri connStr
|
||||
config.CreateConnection ()
|
||||
|
||||
/// Shorthand for the RethinkDB R variable (how every command starts)
|
||||
let r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
open JobsJobsJobs
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open Newtonsoft.Json.Linq
|
||||
open NodaTime.Text
|
||||
open Npgsql.FSharp
|
||||
open RethinkDb.Driver.FSharp.Functions
|
||||
|
||||
/// Retrieve an instant from a JObject field
|
||||
let getInstant (doc : JObject) name =
|
||||
let text = doc[name].Value<string> ()
|
||||
match InstantPattern.General.Parse text with
|
||||
| it when it.Success -> it.Value
|
||||
| _ ->
|
||||
match InstantPattern.ExtendedIso.Parse text with
|
||||
| it when it.Success -> it.Value
|
||||
| it -> raise it.Exception
|
||||
|
||||
task {
|
||||
// Establish database connections
|
||||
let cfg = ConfigurationBuilder().AddJsonFile("appsettings.Migration.json").Build ()
|
||||
use rethinkConn = Rethink.Startup.createConnection (cfg.GetConnectionString "RethinkDB")
|
||||
do! setUp cfg
|
||||
let pgConn = dataSource ()
|
||||
|
||||
let getOld table =
|
||||
fromTable table
|
||||
|> runResult<JObject list>
|
||||
|> withRetryOnce
|
||||
|> withConn rethinkConn
|
||||
|
||||
// Migrate citizens
|
||||
let! oldCitizens = getOld Rethink.Table.Citizen
|
||||
let newCitizens =
|
||||
oldCitizens
|
||||
|> List.map (fun c ->
|
||||
let user = c["mastodonUser"].Value<string> ()
|
||||
{ Citizen.empty with
|
||||
Id = CitizenId.ofString (c["id"].Value<string> ())
|
||||
JoinedOn = getInstant c "joinedOn"
|
||||
LastSeenOn = getInstant c "lastSeenOn"
|
||||
Email = $"""{user}@{c["instance"].Value<string> ()}"""
|
||||
FirstName = user
|
||||
LastName = user
|
||||
IsLegacy = true
|
||||
})
|
||||
for citizen in newCitizens do
|
||||
do! Citizens.Data.save citizen
|
||||
let! _ =
|
||||
pgConn
|
||||
|> Sql.executeTransactionAsync [
|
||||
$"INSERT INTO {Table.SecurityInfo} VALUES (@id, @data)",
|
||||
newCitizens |> List.map (fun c ->
|
||||
let info = { SecurityInfo.empty with Id = c.Id; AccountLocked = true }
|
||||
[ "@id", Sql.string (CitizenId.toString c.Id)
|
||||
"@data", Sql.jsonb (JsonSerializer.Serialize (info, Json.options))
|
||||
])
|
||||
]
|
||||
printfn $"** Migrated {List.length newCitizens} citizens"
|
||||
|
||||
// Migrate continents
|
||||
let! oldContinents = getOld Rethink.Table.Continent
|
||||
let newContinents =
|
||||
oldContinents
|
||||
|> List.map (fun c ->
|
||||
{ Continent.empty with
|
||||
Id = ContinentId.ofString (c["id"].Value<string> ())
|
||||
Name = c["name"].Value<string> ()
|
||||
})
|
||||
let! _ =
|
||||
pgConn
|
||||
|> Sql.executeTransactionAsync [
|
||||
$"INSERT INTO {Table.Continent} VALUES (@id, @data)",
|
||||
newContinents |> List.map (fun c -> [
|
||||
"@id", Sql.string (ContinentId.toString c.Id)
|
||||
"@data", Sql.jsonb (JsonSerializer.Serialize (c, Json.options))
|
||||
])
|
||||
]
|
||||
printfn $"** Migrated {List.length newContinents} continents"
|
||||
|
||||
// Migrate profiles
|
||||
let! oldProfiles = getOld Rethink.Table.Profile
|
||||
let newProfiles =
|
||||
oldProfiles
|
||||
|> List.map (fun p ->
|
||||
let experience = p["experience"].Value<string> ()
|
||||
{ Profile.empty with
|
||||
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||
Region = p["region"].Value<string> ()
|
||||
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||
IsRemote = p["remoteWork"].Value<bool> ()
|
||||
IsFullTime = p["fullTime"].Value<bool> ()
|
||||
Biography = Text (p["biography"].Value<string> ())
|
||||
Experience = if isNull experience then None else Some (Text experience)
|
||||
Skills = p["skills"].Children()
|
||||
|> Seq.map (fun s ->
|
||||
let notes = s["notes"].Value<string> ()
|
||||
{ Description = s["description"].Value<string> ()
|
||||
Notes = if isNull notes then None else Some notes
|
||||
})
|
||||
|> List.ofSeq
|
||||
Visibility = if p["isPublic"].Value<bool> () then Anonymous else Private
|
||||
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
||||
IsLegacy = true
|
||||
})
|
||||
for profile in newProfiles do
|
||||
do! Profiles.Data.save profile
|
||||
printfn $"** Migrated {List.length newProfiles} profiles"
|
||||
|
||||
// Migrate listings
|
||||
let! oldListings = getOld Rethink.Table.Listing
|
||||
let newListings =
|
||||
oldListings
|
||||
|> List.map (fun l ->
|
||||
let neededBy = l["neededBy"].Value<string> ()
|
||||
let wasFilledHere = l["wasFilledHere"].Value<string> ()
|
||||
{ Listing.empty with
|
||||
Id = ListingId.ofString (l["id"].Value<string> ())
|
||||
CitizenId = CitizenId.ofString (l["citizenId"].Value<string> ())
|
||||
CreatedOn = getInstant l "createdOn"
|
||||
Title = l["title"].Value<string> ()
|
||||
ContinentId = ContinentId.ofString (l["continentId"].Value<string> ())
|
||||
Region = l["region"].Value<string> ()
|
||||
IsRemote = l["remoteWork"].Value<bool> ()
|
||||
IsExpired = l["isExpired"].Value<bool> ()
|
||||
UpdatedOn = getInstant l "updatedOn"
|
||||
Text = Text (l["text"].Value<string> ())
|
||||
NeededBy = if isNull neededBy then None else
|
||||
match LocalDatePattern.Iso.Parse neededBy with
|
||||
| it when it.Success -> Some it.Value
|
||||
| it ->
|
||||
eprintfn $"Error parsing date - {it.Exception.Message}"
|
||||
None
|
||||
WasFilledHere = if isNull wasFilledHere then None else Some (bool.Parse wasFilledHere)
|
||||
IsLegacy = true
|
||||
})
|
||||
for listing in newListings do
|
||||
do! Listings.Data.save listing
|
||||
printfn $"** Migrated {List.length newListings} listings"
|
||||
|
||||
// Migrate success stories
|
||||
let! oldSuccesses = getOld Rethink.Table.Success
|
||||
let newSuccesses =
|
||||
oldSuccesses
|
||||
|> List.map (fun s ->
|
||||
let story = s["story"].Value<string> ()
|
||||
{ Success.empty with
|
||||
Id = SuccessId.ofString (s["id"].Value<string> ())
|
||||
CitizenId = CitizenId.ofString (s["citizenId"].Value<string> ())
|
||||
RecordedOn = getInstant s "recordedOn"
|
||||
Source = s["source"].Value<string> ()
|
||||
Story = if isNull story then None else Some (Text story)
|
||||
})
|
||||
for success in newSuccesses do
|
||||
do! SuccessStories.Data.save success
|
||||
printfn $"** Migrated {List.length newSuccesses} successes"
|
||||
|
||||
// Delete any citizens who have no profile, no listing, and no success story recorded
|
||||
let! deleted =
|
||||
pgConn
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Citizen}
|
||||
WHERE id NOT IN (SELECT id FROM {Table.Profile})
|
||||
AND id NOT IN (SELECT DISTINCT data ->> 'citizenId' FROM {Table.Listing})
|
||||
AND id NOT IN (SELECT DISTINCT data ->> 'citizenId' FROM {Table.Success})"
|
||||
|> Sql.executeNonQueryAsync
|
||||
printfn $"** Deleted {deleted} citizens who had no profile, listings, or success stories"
|
||||
|
||||
printfn ""
|
||||
printfn "Migration complete"
|
||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
69
src/JobsJobsJobs/Listings/Data.fs
Normal file
69
src/JobsJobsJobs/Listings/Data.fs
Normal file
@@ -0,0 +1,69 @@
|
||||
module JobsJobsJobs.Listings.Data
|
||||
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Listings.Domain
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// The SQL to select a listing view
|
||||
let viewSql =
|
||||
$"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.Citizen} u ON u.id = l.data ->> 'citizenId'"
|
||||
|
||||
/// Map a result for a listing view
|
||||
let private toListingForView row =
|
||||
{ Listing = toDocument<Listing> row
|
||||
ContinentName = row.string "continent_name"
|
||||
Citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
}
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen citizenId =
|
||||
dataSource ()
|
||||
|> Sql.query $"{viewSql} WHERE l.data ->> 'citizenId' = @citizenId AND l.data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeAsync toListingForView
|
||||
|
||||
/// Find a listing by its ID
|
||||
let findById listingId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with
|
||||
| Some listing when not listing.IsLegacy -> return Some listing
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a listing by its ID for viewing (includes continent information)
|
||||
let findByIdForView listingId = backgroundTask {
|
||||
let! tryListing =
|
||||
dataSource ()
|
||||
|> 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
|
||||
}
|
||||
|
||||
/// Save a listing
|
||||
let save (listing : Listing) =
|
||||
dataSource () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
|
||||
|
||||
/// Search job listings
|
||||
let search (search : ListingSearchForm) =
|
||||
let searches = [
|
||||
if search.ContinentId <> "" then
|
||||
"l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||
if search.Region <> "" then
|
||||
"l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
|
||||
if search.RemoteWork <> "" then
|
||||
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
if search.Text <> "" then
|
||||
"l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
|
||||
]
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
{viewSql}
|
||||
WHERE l.data ->> 'isExpired' = 'false' AND l.data ->> 'isLegacy' = 'false'
|
||||
{searchSql searches}"
|
||||
|> Sql.parameters (searches |> List.collect snd)
|
||||
|> Sql.executeAsync toListingForView
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user