4 Commits
v2 ... v2.2.1

Author SHA1 Message Date
6e52688622 Add API timeout / Fix .NET 6 conversion (#32)
* API requests to Mastodon instances now time out after 30 seconds (inspired by #29)
* Projects now target .NET 6 (#31)
* Minor repo reorg to support single-file deployment (#31)
2021-09-25 11:32:54 -04:00
2ff8618272 Mobile layout / .NET 6 (#28)
- Mobile menu now shows for small and extra-small windows, with the traditional menu showing above those breakpoints (landscape on larger mobile, desktop)
- The back-end is now running on .NET 6 RC 1.
- Use existing library code for page title vs. hand-rolled component
2021-09-17 12:13:32 -04:00
a1d1b53ff4 Support multiple Mastodon instances (#26)
The application handles multiple instances, and gets that information from configuration, making it much easier to bring in additional NA-affiliated instances in the future

Fixes #22
2021-09-06 21:20:51 -04:00
45861e06f0 Remove migration and shared projects (#24) 2021-09-01 15:27:39 -04:00
78 changed files with 19335 additions and 1416 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,3 @@ src/**/bin
src/**/obj src/**/obj
src/**/appsettings.*.json src/**/appsettings.*.json
src/.vs src/.vs
## This stores history of every deployment
src/JobsJobsJobs/Server/Properties/PublishProfiles/FolderProfile.pubxml.user

View File

@@ -13,11 +13,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
EndProject EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}" Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\JobsJobsJobs.Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
EndProject EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}" Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -33,10 +31,6 @@ Global
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -47,6 +41,5 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} {C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} {8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
{C5774E4F-2930-4B64-8407-77BF7EB79F39} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,6 +0,0 @@
{
"Rethink": {
"Hostname": "localhost",
"Db": "jobsjobsjobs"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,19 @@
{ {
"name": "jobs-jobs-jobs", "name": "jobs-jobs-jobs",
"version": "2.0.0", "version": "2.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug" "apiserve": "vue-cli-service build && cd ../Server && dotnet run -c Debug",
"publish": "vue-cli-service build --modern && cd ../Server && dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained false"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^5.9.55", "@mdi/js": "^5.9.55",
"@vuelidate/core": "^2.0.0-alpha.24", "@vuelidate/core": "^2.0.0-alpha.24",
"@vuelidate/validators": "^2.0.0-alpha.21", "@vuelidate/validators": "^2.0.0-alpha.21",
"@vueuse/core": "^6.3.3",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"core-js": "^3.16.3", "core-js": "^3.16.3",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",

View File

@@ -10,11 +10,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue" import { defineComponent, onMounted } from "vue"
import "bootstrap/dist/css/bootstrap.min.css" import "bootstrap/dist/css/bootstrap.min.css"
import { Citizen } from "./api" import { Citizen } from "./api"
import { Mutations, useStore } from "./store"
import AppFooter from "./components/layout/AppFooter.vue" import AppFooter from "./components/layout/AppFooter.vue"
import AppNav from "./components/layout/AppNav.vue" import AppNav from "./components/layout/AppNav.vue"
import AppToaster from "./components/layout/AppToaster.vue" import AppToaster from "./components/layout/AppToaster.vue"
@@ -29,6 +30,10 @@ export default defineComponent({
} }
}) })
const store = useStore()
onMounted(() => store.commit(Mutations.SetTitle, "Jobs, Jobs, Jobs"))
/** /**
* Return "Yes" for true and "No" for false * Return "Yes" for true and "No" for false
* *
@@ -40,13 +45,13 @@ export function yesOrNo (cond : boolean) : string {
} }
/** /**
* Get the display name for a citizen (the first available among real, display, or NAS handle) * Get the display name for a citizen (the first available among real, display, or Mastodon handle)
* *
* @param cit The citizen * @param cit The citizen
* @returns The citizen's display name * @returns The citizen's display name
*/ */
export function citizenName (cit : Citizen) : string { export function citizenName (cit : Citizen) : string {
return cit.realName ?? cit.displayName ?? cit.naUser return cit.realName ?? cit.displayName ?? cit.mastodonUser
} }
</script> </script>

View File

@@ -2,6 +2,7 @@ import {
Citizen, Citizen,
Continent, Continent,
Count, Count,
Instance,
Listing, Listing,
ListingExpireForm, ListingExpireForm,
ListingForm, ListingForm,
@@ -25,7 +26,7 @@ import {
* @param url The partial URL for the API * @param url The partial URL for the API
* @returns A full URL for the API * @returns A full URL for the API
*/ */
const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}` const apiUrl = (url : string) : string => `/api/${url}`
/** /**
* Create request init parameters * Create request init parameters
@@ -100,11 +101,12 @@ export default {
/** /**
* Log a citizen on * Log a citizen on
* *
* @param code The authorization code from No Agenda Social * @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 * @returns The user result, or an error
*/ */
logOn: async (code : string) : Promise<LogOnSuccess | string> => { logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" }) const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
if (resp.status === 200) return await resp.json() as LogOnSuccess if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}` return `Error logging on - ${await resp.text()}`
}, },
@@ -141,6 +143,18 @@ export default {
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents") 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 */ /** API functions for job listings */
listings: { listings: {

View File

@@ -3,8 +3,10 @@
export interface Citizen { export interface Citizen {
/** The ID of the user */ /** The ID of the user */
id : string id : string
/** The abbreviation of the instance where this citizen is based */
instance : string
/** The handle by which the user is known on Mastodon */ /** The handle by which the user is known on Mastodon */
naUser : string mastodonUser : string
/** The user's display name from Mastodon (updated every login) */ /** The user's display name from Mastodon (updated every login) */
displayName : string | undefined displayName : string | undefined
/** The user's real name */ /** The user's real name */
@@ -31,6 +33,18 @@ export interface Count {
count : number 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
}
/** A job listing */ /** A job listing */
export interface Listing { export interface Listing {
/** The ID of the job listing */ /** The ID of the job listing */

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region" input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
@input="updateValue('region', $event.target.value)") @input="updateValue('region', $event.target.value)")
label(for="regionSearch") Region label(for="regionSearch") Region
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Remote Work Opportunity? label.jjj-label Remote Work Opportunity?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -24,13 +24,13 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text" input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
@input="updateValue('text', $event.target.value)") @input="updateValue('text', $event.target.value)")
label(for="textSearch") Job Listing Text label(for="textSearch") Job Listing Text
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

View File

@@ -1,28 +0,0 @@
<template lang="pug">
p(v-if="false")
</template>
<script setup lang="ts">
import { onMounted, watch } from "vue"
const props = defineProps<{
title: string
}>()
/** The name of the application */
const appName = "Jobs, Jobs, Jobs"
/** Set the page title based on the input title attribute */
const setTitle = () => {
if (props.title === "") {
document.title = appName
} else {
document.title = `${props.title} | ${appName}`
}
}
onMounted(setTitle)
/** Change the page title when the property changes */
watch(() => props.title, setTitle)
</script>

View File

@@ -0,0 +1,81 @@
<template lang="pug">
nav
template(v-if="isLoggedOn")
router-link(to="/citizen/dashboard" @click="hide") #[icon(:icon="mdiViewDashboardVariant")]&nbsp; Dashboard
router-link(to="/help-wanted" @click="hide") #[icon(:icon="mdiNewspaperVariantMultipleOutline")]&nbsp; Help Wanted!
router-link(to="/profile/search" @click="hide") #[icon(:icon="mdiViewListOutline")]&nbsp; Employment Profiles
router-link(to="/success-story/list" @click="hide") #[icon(:icon="mdiThumbUp")]&nbsp; Success Stories
.separator
router-link(to="/listings/mine" @click="hide") #[icon(:icon="mdiSignText")]&nbsp; My Job Listings
router-link(to="/citizen/profile" @click="hide") #[icon(:icon="mdiPencil")]&nbsp; My Employment Profile
.separator
router-link(to="/citizen/log-off" @click="hide") #[icon(:icon="mdiLogoutVariant")]&nbsp; Log Off
template(v-else)
router-link(to="/" @click="hide") #[icon(:icon="mdiHome")]&nbsp; Home
router-link(to="/profile/seeking" @click="hide") #[icon(:icon="mdiViewListOutline")]&nbsp; Job Seekers
router-link(to="/citizen/log-on" @click="hide") #[icon(:icon="mdiLoginVariant")]&nbsp; Log On
router-link(to="/how-it-works" @click="hide") #[icon(:icon="mdiHelpCircleOutline")]&nbsp; 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>

View File

@@ -1,88 +1,45 @@
<template lang="pug"> <template lang="pug">
aside.collapse.show.p-3 #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.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
p &nbsp; p &nbsp;
nav app-links
template(v-if="isLoggedOn")
router-link(to="/citizen/dashboard") #[icon(:icon="mdiViewDashboardVariant")]&nbsp; Dashboard
router-link(to="/help-wanted") #[icon(:icon="mdiNewspaperVariantMultipleOutline")]&nbsp; Help Wanted!
router-link(to="/profile/search") #[icon(:icon="mdiViewListOutline")]&nbsp; Employment Profiles
router-link(to="/success-story/list") #[icon(:icon="mdiThumbUp")]&nbsp; Success Stories
.separator
router-link(to="/listings/mine") #[icon(:icon="mdiSignText")]&nbsp; My Job Listings
router-link(to="/citizen/profile") #[icon(:icon="mdiPencil")]&nbsp; My Employment Profile
.separator
router-link(to="/citizen/log-off") #[icon(:icon="mdiLogoutVariant")]&nbsp; Log Off
template(v-else)
router-link(to="/") #[icon(:icon="mdiHome")]&nbsp; Home
router-link(to="/profile/seeking") #[icon(:icon="mdiViewListOutline")]&nbsp; Job Seekers
router-link(to="/citizen/log-on") #[icon(:icon="mdiLoginVariant")]&nbsp; Log On
router-link(to="/how-it-works") #[icon(:icon="mdiHelpCircleOutline")]&nbsp; How It Works
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import { useStore } from "@/store" import AppLinks from "./AppLinks.vue"
import {
mdiHelpCircleOutline,
mdiHome,
mdiLoginVariant,
mdiLogoutVariant,
mdiNewspaperVariantMultipleOutline,
mdiPencil,
mdiSignText,
mdiThumbUp,
mdiViewDashboardVariant,
mdiViewListOutline
} from "@mdi/js"
const store = useStore() const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether a user is logged in or not */ /** Whether the mobile menu or the desktop menu should be shown */
const isLoggedOn = computed(() => store.state.user !== undefined) const showMobileMenu = breakpoints.smaller("md")
</script> </script>
<style lang="sass" scoped> <style lang="sass" scoped>
aside aside,
#mobileMenu
background-image: linear-gradient(180deg, darkgreen 0%, green 70%) background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
color: white color: white
font-size: 1.2rem font-size: 1.2rem
aside
min-height: 100vh min-height: 100vh
width: 250px width: 250px
min-width: 250px min-width: 250px
position: sticky position: sticky
top: 0 top: 0
path
fill: white
path:hover
fill: black
a:link, a:visited
text-decoration: none
color: white
// font-weight: 500
.home-link .home-link
font-size: 1.2rem font-size: 1.2rem
text-align: center text-align: center
background-color: rgba(0, 0, 0, .4) background-color: rgba(0, 0, 0, .4)
margin: -1rem margin: -1rem
padding: 1rem padding: 1rem
nav > a a:link,
display: block a:visited
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 text-decoration: none
nav > div.separator color: white
border-bottom: solid 1px rgba(255, 255, 255, .75)
height: 1px
</style> </style>

View File

@@ -1,16 +1,38 @@
<template lang="pug"> <template lang="pug">
nav.navbar.navbar-light.bg-light 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 &nbsp; span &nbsp;
span.navbar-text. span.navbar-text.
(&hellip;and Jobs &ndash; #[audio-clip(clip="pelosi-jobs") Let&rsquo;s Vote for Jobs!]) (&hellip;and Jobs &ndash; #[audio-clip(clip="pelosi-jobs") Let&rsquo;s Vote for Jobs!])
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { mdiMenu } from "@mdi/js"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import AudioClip from "@/components/AudioClip.vue" import AudioClip from "@/components/AudioClip.vue"
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether to show the mobile or desktop header */
const showMobileHeader = breakpoints.smaller("md")
</script> </script>
<style lang="sass" scoped> <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 .navbar-text
font-style: italic font-style: italic
padding-right: 1rem padding: 0 1rem 0 0
</style> </style>

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
.form-floating .form-floating
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)" input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
:value="criteria.region" @input="updateValue('region', $event.target.value)") :value="criteria.region" @input="updateValue('region', $event.target.value)")
label(for="region") Region label(for="region") Region
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Seeking Remote Work? label.jjj-label Seeking Remote Work?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -24,13 +24,13 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)" input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
:value="criteria.skill" @input="updateValue('skill', $event.target.value)") :value="criteria.skill" @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill label(for="skillSearch") Skill
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

View File

@@ -1,9 +1,9 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Seeking Remote Work? label.jjj-label Seeking Remote Work?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -18,19 +18,19 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill" input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
@input="updateValue('skill', $event.target.value)") @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill label(for="skillSearch") Skill
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience" input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
@input="updateValue('bioExperience', $event.target.value)") @input="updateValue('bioExperience', $event.target.value)")
label(for="bioSearch") Bio / Experience label(for="bioSearch") Bio / Experience
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
.row.pb-3 .row.pb-3
.col.col-xs-2.col-md-1.align-self-center .col-xs-2.col-md-1.align-self-center
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") &nbsp;&minus;&nbsp; button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") &nbsp;&minus;&nbsp;
.col.col-xs-10.col-md-6 .col-xs-10.col-md-6
.form-floating .form-floating
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100" input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description" placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
@input="updateValue('description', $event.target.value)") @input="updateValue('description', $event.target.value)")
label.jjj-label(:for="`skillDesc${skill.id}`") Skill label.jjj-label(:for="`skillDesc${skill.id}`") Skill
.form-text A skill (language, design technique, process, etc.) .form-text A skill (language, design technique, process, etc.)
.col.col-xs-12.col-md-5 .col-xs-12.col-md-5
.form-floating .form-floating
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100" input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
placeholder="A further description of the skill (100 characters max)" :value="skill.notes" placeholder="A further description of the skill (100 characters max)" :value="skill.notes"

View File

@@ -3,13 +3,11 @@ import App from "./App.vue"
import router from "./router" import router from "./router"
import store, { key } from "./store" import store, { key } from "./store"
import Icon from "./components/Icon.vue" import Icon from "./components/Icon.vue"
import PageTitle from "./components/PageTitle.vue"
const app = createApp(App) const app = createApp(App)
.use(router) .use(router)
.use(store, key) .use(store, key)
app.component("Icon", Icon) app.component("Icon", Icon)
app.component("PageTitle", PageTitle)
app.mount("#app") app.mount("#app")

View File

@@ -3,14 +3,13 @@ import {
createWebHistory, createWebHistory,
RouteLocationNormalized, RouteLocationNormalized,
RouteLocationNormalizedLoaded, RouteLocationNormalizedLoaded,
RouteRecordName,
RouteRecordRaw RouteRecordRaw
} from "vue-router" } from "vue-router"
import store from "@/store" import store, { Mutations } from "@/store"
import Home from "@/views/Home.vue" import Home from "@/views/Home.vue"
import LogOn from "@/views/citizen/LogOn.vue" import LogOn from "@/views/citizen/LogOn.vue"
/** The URL to which the user should be pointed once they have authorized with NAS */ /** 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" export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
/** /**
@@ -29,124 +28,141 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: Home component: Home,
meta: { title: "Welcome!" }
}, },
{ {
path: "/how-it-works", path: "/how-it-works",
name: "HowItWorks", name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue") component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" }
}, },
{ {
path: "/privacy-policy", path: "/privacy-policy",
name: "PrivacyPolicy", name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue") component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" }
}, },
{ {
path: "/terms-of-service", path: "/terms-of-service",
name: "TermsOfService", name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue") component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" }
}, },
// Citizen URLs // Citizen URLs
{ {
path: "/citizen/log-on", path: "/citizen/log-on",
name: "LogOn", name: "LogOn",
component: LogOn component: LogOn,
meta: { title: "Log On" }
}, },
{ {
path: "/citizen/authorized", path: "/citizen/:abbr/authorized",
name: "CitizenAuthorized", name: "CitizenAuthorized",
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue") component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue"),
meta: { title: "Logging On" }
}, },
{ {
path: "/citizen/dashboard", path: "/citizen/dashboard",
name: "Dashboard", name: "Dashboard",
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue") component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
meta: { auth: true, title: "Dashboard" }
}, },
{ {
path: "/citizen/profile", path: "/citizen/profile",
name: "EditProfile", name: "EditProfile",
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue") component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue"),
meta: { auth: true, title: "Edit Profile" }
}, },
{ {
path: "/citizen/log-off", path: "/citizen/log-off",
name: "LogOff", name: "LogOff",
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue") component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"),
meta: { auth: true, title: "Logging Off" }
}, },
// Job Listing URLs // Job Listing URLs
{ {
path: "/help-wanted", path: "/help-wanted",
name: "HelpWanted", name: "HelpWanted",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
meta: { auth: true, title: "Help Wanted" }
}, },
{ {
path: "/listing/:id/edit", path: "/listing/:id/edit",
name: "EditListing", name: "EditListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue") component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue"),
meta: { auth: true, title: "Edit Job Listing" }
}, },
{ {
path: "/listing/:id/expire", path: "/listing/:id/expire",
name: "ExpireListing", name: "ExpireListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue") component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue"),
meta: { auth: true, title: "Expire Job Listing" }
}, },
{ {
path: "/listing/:id/view", path: "/listing/:id/view",
name: "ViewListing", name: "ViewListing",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue"),
meta: { auth: true, title: "Loading Job Listing..." }
}, },
{ {
path: "/listings/mine", path: "/listings/mine",
name: "MyListings", name: "MyListings",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue"),
meta: { auth: true, title: "My Job Listings" }
}, },
// Profile URLs // Profile URLs
{ {
path: "/profile/:id/view", path: "/profile/:id/view",
name: "ViewProfile", name: "ViewProfile",
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue") component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"),
meta: { auth: true, title: "Loading Profile..." }
}, },
{ {
path: "/profile/search", path: "/profile/search",
name: "SearchProfiles", name: "SearchProfiles",
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue") component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
meta: { auth: true, title: "Search Profiles" }
}, },
{ {
path: "/profile/seeking", path: "/profile/seeking",
name: "PublicSearchProfiles", name: "PublicSearchProfiles",
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue") component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue"),
meta: { auth: false, title: "People Seeking Work" }
}, },
// "So Long" URLs // "So Long" URLs
{ {
path: "/so-long/options", path: "/so-long/options",
name: "DeletionOptions", name: "DeletionOptions",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue"),
meta: { auth: true, title: "Account Deletion Options" }
}, },
{ {
path: "/so-long/success", path: "/so-long/success/:abbr",
name: "DeletionSuccess", name: "DeletionSuccess",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
meta: { auth: false, title: "Account Deletion Success" }
}, },
// Success Story URLs // Success Story URLs
{ {
path: "/success-story/list", path: "/success-story/list",
name: "ListStories", name: "ListStories",
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue") component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue"),
meta: { auth: false, title: "Success Stories" }
}, },
{ {
path: "/success-story/:id/edit", path: "/success-story/:id/edit",
name: "EditStory", name: "EditStory",
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue") component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue"),
meta: { auth: false, title: "Edit Success Story" }
}, },
{ {
path: "/success-story/:id/view", path: "/success-story/:id/view",
name: "ViewStory", name: "ViewStory",
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue") component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue"),
meta: { auth: false, title: "Success Story" }
} }
] ]
/** The routes that do not require logins */
const publicRoutes : Array<RouteRecordName> = [
"Home", "HowItWorks", "PrivacyPolicy", "TermsOfService", "LogOn", "CitizenAuthorized", "PublicSearchProfiles",
"DeletionSuccess"
]
const router = createRouter({ const router = createRouter({
history: createWebHistory(process.env.BASE_URL), history: createWebHistory(process.env.BASE_URL),
@@ -159,10 +175,11 @@ const router = createRouter({
// eslint-disable-next-line // eslint-disable-next-line
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => { router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
if (store.state.user === undefined && !publicRoutes.includes(to.name ?? "")) { if (store.state.user === undefined && (to.meta.auth || false)) {
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath) window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
return "/citizen/log-on" return "/citizen/log-on"
} }
store.commit(Mutations.SetTitle, to.meta.title ?? "")
}) })
export default router export default router

View File

@@ -0,0 +1,8 @@
/** 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"

View File

@@ -1,15 +1,22 @@
import { useTitle } from "@vueuse/core"
import { InjectionKey } from "vue" import { InjectionKey } from "vue"
import { createStore, Store, useStore as baseUseStore } from "vuex" import { createStore, Store, useStore as baseUseStore } from "vuex"
import api, { Continent, LogOnSuccess } from "../api" import api, { Continent, Instance, LogOnSuccess } from "../api"
import * as Actions from "./actions"
import * as Mutations from "./mutations"
/** The state tracked by the application */ /** The state tracked by the application */
export interface State { export interface State {
/** The document's current title */
pageTitle : string
/** The currently logged-on user */ /** The currently logged-on user */
user : LogOnSuccess | undefined user : LogOnSuccess | undefined
/** The state of the log on process */ /** The state of the log on process */
logOnState : string logOnState : string
/** All continents (use `ensureContinents` action) */ /** All continents (use `ensureContinents` action) */
continents : Continent[] continents : Continent[]
/** All instances (use `ensureInstances` action) */
instances : Instance[]
} }
/** An injection key to identify this state with Vue */ /** An injection key to identify this state with Vue */
@@ -20,47 +27,63 @@ export function useStore () : Store<State> {
return baseUseStore(key) return baseUseStore(key)
} }
/** The application name */
const appName = "Jobs, Jobs, Jobs"
export default createStore({ export default createStore({
state: () : State => { state: () : State => {
return { return {
pageTitle: "",
user: undefined, user: undefined,
logOnState: "<em>Welcome back! Verifying your No Agenda Social account&hellip;</em>", logOnState: "<em>Welcome back!</em>",
continents: [] continents: [],
instances: []
} }
}, },
mutations: { mutations: {
setUser (state, user : LogOnSuccess) { [Mutations.SetTitle]: (state, title : string) => {
state.user = user state.pageTitle = title === "" ? appName : `${title} | ${appName}`
useTitle(state.pageTitle)
}, },
clearUser (state) { [Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
state.user = undefined [Mutations.ClearUser]: (state) => { state.user = undefined },
}, [Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
setLogOnState (state, message : string) { [Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
state.logOnState = message [Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
},
setContinents (state, continents : Continent[]) {
state.continents = continents
}
}, },
actions: { actions: {
async logOn ({ commit }, code: string) { [Actions.LogOn]: async ({ commit }, { abbr, code }) => {
const logOnResult = await api.citizen.logOn(code) const logOnResult = await api.citizen.logOn(abbr, code)
if (typeof logOnResult === "string") { if (typeof logOnResult === "string") {
commit("setLogOnState", logOnResult) commit(Mutations.SetLogOnState, logOnResult)
} else { } else {
commit("setUser", logOnResult) commit(Mutations.SetUser, logOnResult)
} }
}, },
async ensureContinents ({ state, commit }) { [Actions.EnsureContinents]: async ({ state, commit }) => {
if (state.continents.length > 0) return if (state.continents.length > 0) return
const theSeven = await api.continent.all() const theSeven = await api.continent.all()
if (typeof theSeven === "string") { if (typeof theSeven === "string") {
console.error(theSeven) console.error(theSeven)
} else { } else {
commit("setContinents", theSeven) 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: { modules: {
} }
}) })
export * as Actions from "./actions"
export * as Mutations from "./mutations"

View File

@@ -0,0 +1,17 @@
/** 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"

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Welcome!")
p &nbsp; p &nbsp;
p. p.
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="How It Works")
h3 How It Works h3 How It Works
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021 h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
p: em. p: em.
@@ -21,8 +20,8 @@ article
p. p.
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all 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. of the information from the search results, along with the citizen who posted it, and the full details of the job.
The citizen&rsquo;s name is a link to their profile page at No Agenda Social; you can use that to get their handle, The citizen&rsquo;s name is a link to their profile page at their Mastodon instance; you can use that to get their
and use NAS&rsquo;s communication facilites to inquire about the position. handle, and use Mastodon&rsquo;s communication facilites to inquire about the position.
p: em.text-muted. 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; (If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
I&rsquo;ve searched and searched, and asked NAS, but have not yet determined how to do that.) I&rsquo;ve searched and searched, and asked NAS, but have not yet determined how to do that.)
@@ -43,9 +42,9 @@ article
The #[span.link My Job Listings] page will show you all of your active job listings just below the 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 #[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 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 over on No Agenda Social, and those who click on it will be able to view it. (Existing the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s access, but then they view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s
will get there as well.) access, but then they will get there as well.)
h5 Expire a Job Listing h5 Expire a Job Listing
p. p.
@@ -68,7 +67,7 @@ article
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you
find someone with whom you&rsquo;d like to discuss potential opportunities, the name at the top of the profile links find someone with whom you&rsquo;d like to discuss potential opportunities, the name at the top of the profile links
to their No Agenda Social account, where you can use its features to get in touch. to their Mastodon profile, where you can use its features to get in touch.
hr hr
@@ -76,8 +75,8 @@ article
p. p.
The employment profile is your r&eacute;sum&eacute;, visible to other citizens here. It also allows you to specify The employment profile is your r&eacute;sum&eacute;, 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, 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 No Agenda Social; this system updates your current profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
display name each time you log on. current display name each time you log on.
h5 Completing Your Profile h5 Completing Your Profile
p. p.
@@ -99,19 +98,19 @@ article
li. li.
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking 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 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 No Agenda Social handle or real name; they are there to let people peek site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
behind the curtain a bit, and hopefully inspire them to join us. curtain a bit, and hopefully inspire them to join us.
h5 Viewing and Sharing Your Profile h5 Viewing and Sharing Your Profile
p. p.
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom 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 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 No Agenda Social, if you link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
would like to share it there. Just as with job listings, existing users will go straight there, while other No Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
Agenda Social users will get there once they authorize this application. there, while others will get there once they authorize this application.
p. p.
The name on employment profiles is a link to that user&rsquo;s profile on No Agenda Social; from there, others can The name on employment profiles is a link to that user&rsquo;s profile on their Mastodon instance; from there,
communicate further with you using the tools Mastodon provides. others can communicate further with you using the tools Mastodon provides.
h5 &ldquo;I Found a Job!&rdquo; h5 &ldquo;I Found a Job!&rdquo;
p. p.

View File

@@ -1,8 +1,7 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Privacy Policy")
h3 Privacy Policy h3 Privacy Policy
p: em (as of February 6#[sup th], 2021) p: em (as of September 6#[sup th], 2021)
p. p.
{{name}} (&ldquo;we,&rdquo; &ldquo;our,&rdquo; or &ldquo;us&rdquo;) is committed to protecting your privacy. This {{name}} (&ldquo;we,&rdquo; &ldquo;our,&rdquo; or &ldquo;us&rdquo;) is committed to protecting your privacy. This
@@ -58,7 +57,7 @@ article
li Name / Username li Name / Username
li Coarse Geographic Location li Coarse Geographic Location
li Employment History li Employment History
li No Agenda Social Account Name / Profile li Mastodon Account Name / Profile
h4 How Do We Use The Information We Collect? 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: p Any of the information we collect from you may be used in one of the following ways:
@@ -75,9 +74,9 @@ article
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers. p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
p. p.
End users may voluntarily provide us with information they have made available on social media websites End users may voluntarily provide us with information they have made available on social media websites
(specifically No Agenda Social). If you provide us with any such information, we may collect publicly available (specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
information from the social media websites you have indicated. You can control how much of your information social publicly available information from the social media websites you have indicated. You can control how much of your
media websites make public by visiting these websites and changing your privacy settings. 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? 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. p We do not utilize third party information apart from the end-user data described above.
@@ -223,10 +222,10 @@ article
h4 Tracking Technologies h4 Tracking Technologies
p. p.
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that {{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
token is stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is refreshed
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
application can be used again. can be used again.
h4 Information about General Data Protection Regulation (GDPR) h4 Information about General Data Protection Regulation (GDPR)
p. p.
@@ -335,6 +334,12 @@ article
h4 Contact Us h4 Contact Us
p Don&rsquo;t hesitate to contact us if you have any questions. p Don&rsquo;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] 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 &ndash; replaced &ldquo;No Agenda Social&rdquo; with generic terms for any
authorized Mastodon instance.
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,8 +1,7 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Terms of Service")
h3 Terms of Service h3 Terms of Service
p: em (as of February 6#[sup th], 2021) p: em (as of September 6#[sup th], 2021)
h4 Acceptance of Terms h4 Acceptance of Terms
p. p.
@@ -11,12 +10,19 @@ article
acceptance of these terms. acceptance of these terms.
h4 Description of Service and Registration h4 Description of Service and Registration
p. p
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access | 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 | to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing = " "
Jobs, Jobs, Jobs to read one&rsquo;s No Agenda Social profile. See our template(v-for="(it, idx) in instances" :key="idx")
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain. 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&rsquo;s Mastodon profile. See our
= " "
router-link(to="/privacy-policy") privacy policy
= " "
| for details on the personal (user) information we maintain.
h4 Liability h4 Liability
p. p.
@@ -34,4 +40,23 @@ article
p. p.
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
data. data.
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with a list of all No
Agenda-affiliated Mastodon instances.
</template> </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>

View File

@@ -1,24 +1,36 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Logging on...")
p &nbsp; p &nbsp;
p(v-html="message") p(v-html="message")
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue" import { computed, onMounted } from "vue"
import { useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useStore } from "@/store" import { useStore, Actions, Mutations } from "@/store"
import { AFTER_LOG_ON_URL } from "@/router" import { AFTER_LOG_ON_URL } from "@/router"
const router = useRouter()
const store = useStore() 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 */ /** Pass the code to the API and exchange it for a user and a JWT */
const logOn = async () => { const logOn = async () => {
const code = router.currentRoute.value.query.code 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&hellip;</em>`)
const code = route.query.code
if (code) { if (code) {
await store.dispatch("logOn", code) await store.dispatch(Actions.LogOn, { abbr, code })
if (store.state.user !== undefined) { if (store.state.user !== undefined) {
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL) const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) { if (afterLogOnUrl) {
@@ -29,8 +41,8 @@ const logOn = async () => {
} }
} }
} else { } else {
store.commit("setLogOnState", setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked &ldquo;Cancel&rdquo;?)`)
"Did not receive a token from No Agenda Social (perhaps you clicked &ldquo;Cancel&rdquo;?)") }
} }
} }

View File

@@ -1,12 +1,12 @@
<template lang="pug"> <template lang="pug">
article.container article.container
page-title(title="Dashboard")
h3.pb-4 Welcome, {{user.name}} h3.pb-4 Welcome, {{user.name}}
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2 load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
.col: .card.h-100 .col: .card.h-100
h5.card-header Your Profile h5.card-header Your Profile
.card-body .card-body
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")] 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") p.card-text(v-if="profile")
| Your profile currently lists {{profile.skills.length}} | Your profile currently lists {{profile.skills.length}}
| skill#[template(v-if="profile.skills.length !== 1") s]. | skill#[template(v-if="profile.skills.length !== 1") s].

View File

@@ -1,14 +1,13 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Edit Profile")
h3.pb-3 My Employment Profile h3.pb-3 My Employment Profile
load-data(:load="retrieveData"): form.row.g-3 load-data(:load="retrieveData"): form.row.g-3
.col-12.col-sm-10.col-md-8.col-lg-6 .col-12.col-sm-10.col-md-8.col-lg-6
.form-floating .form-floating
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255" input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
placeholder="Leave blank to use your NAS display name") placeholder="Leave blank to use your Mastodon display name")
label(for="realName") Real Name label(for="realName") Real Name
.form-text Leave blank to use your NAS display name .form-text Leave blank to use your Mastodon display name
.col-12 .col-12
.form-check .form-check
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model") input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Logging off...")
p &nbsp; p &nbsp;
p.fst-italic Logging off&hellip; p.fst-italic Logging off&hellip;
</template> </template>
@@ -9,13 +8,13 @@ article
import { onMounted } from "vue" import { onMounted } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { toastSuccess } from "@/components/layout/AppToaster.vue" import { toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { useStore, Mutations } from "@/store"
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
onMounted(() => { onMounted(() => {
store.commit("clearUser") store.commit(Mutations.ClearUser)
toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>") toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>")
router.push("/") router.push("/")
}) })

View File

@@ -1,24 +1,50 @@
<template lang="pug"> <template lang="pug">
article article
p &nbsp; p &nbsp;
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second&hellip; p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second&hellip;
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")
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** import { computed, onMounted, Ref, ref } from "vue"
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it import { Instance } from "@/api"
* can be called from two different places, and allow the app to support direct links to authorized content. 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 */ /** The authorization URL to which the user should be directed */
const authUrl = (() => { const authUrl = computed(() => {
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */ if (selected.value) {
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU" const client = `client_id=${selected.value.clientId}`
const client = `client_id=${id}`
const scope = "scope=read:accounts" const scope = "scope=read:accounts"
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized` const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
const respType = "response_type=code" const respType = "response_type=code"
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}` return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
})() }
document.location.assign(authUrl) 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> </script>

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Help Wanted")
h3.pb-3 Help Wanted h3.pb-3 Help Wanted
p(v-if="!searched"). p(v-if="!searched").
Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all current job listings. Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all current job listings.

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(:title="isNew ? 'Add a Job Listing' : 'Edit Job Listing'")
h3.pb-3(v-if="isNew") Add a Job Listing h3.pb-3(v-if="isNew") Add a Job Listing
h3.pb-3(v-else) Edit Job Listing h3.pb-3(v-else) Edit Job Listing
load-data(:load="retrieveData"): form.row.g-3 load-data(:load="retrieveData"): form.row.g-3
@@ -44,7 +43,7 @@ import { required } from "@vuelidate/validators"
import api, { Listing, ListingForm, LogOnSuccess } from "@/api" import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import ContinentList from "@/components/ContinentList.vue" import ContinentList from "@/components/ContinentList.vue"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
@@ -99,6 +98,7 @@ const v$ = useVuelidate(rules, listing, { $lazy: true })
/** Retrieve the listing being edited (or set up the form for a new listing) */ /** Retrieve the listing being edited (or set up the form for a new listing) */
const retrieveData = async (errors : string[]) => { 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) const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
if (typeof listResult === "string") { if (typeof listResult === "string") {
errors.push(listResult) errors.push(listResult)

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Expire Job Listing")
load-data(:load="retrieveListing") load-data(:load="retrieveListing")
h3.pb-3 Expire Job Listing ({{listing.title}}) h3.pb-3 Expire Job Listing ({{listing.title}})
p: em. p: em.

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(:title="title")
load-data(:load="retrieveListing") load-data(:load="retrieveListing")
h3 h3
| {{it.listing.title}} | {{it.listing.title}}
@@ -24,7 +23,7 @@ import { formatNeededBy } from "./"
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api" import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
import { citizenName } from "@/App.vue" import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown" import { toHtml } from "@/markdown"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
const store = useStore() const store = useStore()
@@ -48,6 +47,7 @@ const retrieveListing = async (errors : string[]) => {
errors.push("Job Listing not found") errors.push("Job Listing not found")
} else { } else {
it.value = listingResp it.value = listingResp
store.commit(Mutations.SetTitle, `${listingResp.listing.title} | Job Listing`)
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user) const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
if (typeof citizenResp === "string") { if (typeof citizenResp === "string") {
errors.push(citizenResp) errors.push(citizenResp)
@@ -59,14 +59,11 @@ const retrieveListing = async (errors : string[]) => {
} }
} }
/** The page title (changes once the listing is loaded) */
const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing` : "Loading Job Listing...")
/** The HTML details of the job listing */ /** The HTML details of the job listing */
const details = computed(() => toHtml(it.value?.listing.text ?? "")) const details = computed(() => toHtml(it.value?.listing.text ?? ""))
/** The NAS profile URL for the citizen who posted this job listing */ /** The Mastodon profile URL for the citizen who posted this job listing */
const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "") const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
/** The needed by date, formatted in SHOUTING MODE */ /** The needed by date, formatted in SHOUTING MODE */
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase() const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="My Job Listings")
h3.pb-3 My Job Listings h3.pb-3 My Job Listings
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
load-data(:load="getListings") load-data(:load="getListings")

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Search Profiles")
h3.pb-3 Search Profiles h3.pb-3 Search Profiles
p(v-if="!searched"). p(v-if="!searched").
Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles. Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles.
@@ -13,23 +12,25 @@ article
thead: tr thead: tr
th(scope="col") Profile th(scope="col") Profile
th(scope="col") Name th(scope="col") Name
th.text-center(scope="col") Seeking? th.text-center(scope="col" v-if="wideDisplay") Seeking?
th.text-center(scope="col") Remote? th.text-center(scope="col") Remote?
th.text-center(scope="col") Full-Time? th.text-center(scope="col" v-if="wideDisplay") Full-Time?
th(scope="col") Last Updated th(scope="col" v-if="wideDisplay") Last Updated
tbody: tr(v-for="profile in results" :key="profile.citzenId") tbody: tr(v-for="profile in results" :key="profile.citzenId")
td: router-link(:to="`/profile/${profile.citizenId}/view`") View td: router-link(:to="`/profile/${profile.citizenId}/view`") View
td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}} td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}}
td.text-center {{yesOrNo(profile.seekingEmployment)}} td.text-center(v-if="wideDisplay") {{yesOrNo(profile.seekingEmployment)}}
td.text-center {{yesOrNo(profile.remoteWork)}} td.text-center {{yesOrNo(profile.remoteWork)}}
td.text-center {{yesOrNo(profile.fullTime)}} td.text-center(v-if="wideDisplay") {{yesOrNo(profile.fullTime)}}
td: full-date(:date="profile.lastUpdatedOn") td(v-if="wideDisplay"): full-date(:date="profile.lastUpdatedOn")
p.pt-3(v-else-if="searched") No results found for the specified criteria p.pt-3(v-else-if="searched") No results found for the specified criteria
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, Ref, ref, watch } from "vue" import { Ref, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import { yesOrNo } from "@/App.vue" import { yesOrNo } from "@/App.vue"
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api" import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
import { queryValue } from "@/router" import { queryValue } from "@/router"
@@ -43,6 +44,7 @@ import ProfileSearchForm from "@/components/profile/SearchForm.vue"
const store = useStore() const store = useStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Any errors encountered while retrieving data */ /** Any errors encountered while retrieving data */
const errors : Ref<string[]> = ref([]) const errors : Ref<string[]> = ref([])
@@ -70,6 +72,9 @@ const results : Ref<ProfileSearchResult[]> = ref([])
/** Whether the search criteria should be collapsed */ /** Whether the search criteria should be collapsed */
const isCollapsed = ref(searched.value && results.value.length > 0) 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 */ /** Set up the page to match its requested state */
const setUpPage = async () => { const setUpPage = async () => {
if (queryValue(route, "searched") === "true") { if (queryValue(route, "searched") === "true") {

View File

@@ -34,7 +34,7 @@ import { mdiPencil } from "@mdi/js"
import api, { LogOnSuccess, ProfileForView } from "@/api" import api, { LogOnSuccess, ProfileForView } from "@/api"
import { citizenName } from "@/App.vue" import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown" import { toHtml } from "@/markdown"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
const store = useStore() const store = useStore()
@@ -66,14 +66,10 @@ const retrieveProfile = async (errors : string[]) => {
errors.push("Profile not found") errors.push("Profile not found")
} else { } else {
it.value = profileResp it.value = profileResp
store.commit(Mutations.SetTitle, `Employment profile for ${citizenName(profileResp.citizen)}`)
} }
} }
/** The title of the page (changes once the profile is loaded) */
const title = computed(() => it.value
? `Employment profile for ${citizenName(it.value.citizen)}`
: "Loading Profile...")
/** The HTML version of the citizen's professional biography */ /** The HTML version of the citizen's professional biography */
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? "")) const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="People Seeking Work")
h3.pb-3 People Seeking Work h3.pb-3 People Seeking Work
p(v-if="!searched"). p(v-if="!searched").
Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles. Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles.

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Account Deletion Options")
h3.pb-3 Account Deletion Options h3.pb-3 Account Deletion Options
h4.pb-3 Option 1 &ndash; Delete Your Profile h4.pb-3 Option 1 &ndash; Delete Your Profile
p. p.
@@ -13,28 +12,31 @@ article
p. p.
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and 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 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 No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs. #[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs.
p: em. p: em.
(This will not revoke this application&rsquo;s permissions on No Agenda Social; you will have to remove this (This will not revoke this application&rsquo;s permissions on Mastodon; you will have to remove this yourself. The
yourself. The confirmation message has a link where you can do this; once the page loads, find 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 &times; Revoke] link for that entry.) #[strong Jobs, Jobs, Jobs] entry, and click the #[strong &times; Revoke] link for that entry.)
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import api, { LogOnSuccess } from "@/api" import api, { LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { useStore, Actions, Mutations } from "@/store"
</script>
<script setup lang="ts">
const store = useStore() const store = useStore()
const router = useRouter() 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 */ /** Delete the profile only; redirect to home page on success */
const deleteProfile = async () => { const deleteProfile = async () => {
const resp = await api.profile.delete(store.state.user as LogOnSuccess) const resp = await api.profile.delete(user)
if (typeof resp === "string") { if (typeof resp === "string") {
toastError(resp, "Deleting Profile") toastError(resp, "Deleting Profile")
} else { } else {
@@ -45,13 +47,28 @@ const deleteProfile = async () => {
/** Delete everything pertaining to the user's account */ /** Delete everything pertaining to the user's account */
const deleteAccount = async () => { const deleteAccount = async () => {
const resp = await api.citizen.delete(store.state.user as LogOnSuccess) 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") { if (typeof resp === "string") {
toastError(resp, "Deleting Account") toastError(resp, "Deleting Account")
} else { } else {
store.commit("clearUser") store.commit(Mutations.ClearUser)
toastSuccess("Account Deleted Successfully") toastSuccess("Account Deleted Successfully")
router.push("/so-long/success") router.push(`/so-long/success/${instance.abbr}`)
} }
} }
}
}
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@@ -1,11 +1,28 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Account Deletion Success")
h3.pb-3 Account Deletion Success h3.pb-3 Account Deletion Success
p. p.
Your account has been successfully deleted. To revoke the permissions you have previously granted to this Your account has been successfully deleted. To revoke the permissions you have previously granted to this
application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
#[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty #[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty
account without prompting you further. account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation p Thank you for participating, and thank you for your courage. #GitmoNation
</template> </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>

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(:title="title")
h3.pb-3 {{title}} h3.pb-3 {{title}}
load-data(:load="retrieveStory") load-data(:load="retrieveStory")
p(v-if="isNew"). p(v-if="isNew").
@@ -26,7 +25,7 @@ import useVuelidate from "@vuelidate/core"
import api, { LogOnSuccess, StoryForm } from "@/api" import api, { LogOnSuccess, StoryForm } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue" import MarkdownEditor from "@/components/MarkdownEditor.vue"
@@ -45,9 +44,6 @@ const id = route.params.id as string
/** Whether this is a new story */ /** Whether this is a new story */
const isNew = computed(() => id === "new") const isNew = computed(() => id === "new")
/** The page title */
const title = computed(() => isNew.value ? "Tell Your Success Story" : "Edit Success Story")
/** The form for editing the story */ /** The form for editing the story */
const story = reactive(new StoryForm()) const story = reactive(new StoryForm())
@@ -64,6 +60,7 @@ const v$ = useVuelidate(rules, story, { $lazy: true })
const retrieveStory = async (errors : string[]) => { const retrieveStory = async (errors : string[]) => {
if (isNew.value) { if (isNew.value) {
story.id = "new" story.id = "new"
store.commit(Mutations.SetTitle, "Tell Your Success Story")
} else { } else {
const storyResult = await api.success.retrieve(id, user) const storyResult = await api.success.retrieve(id, user)
if (typeof storyResult === "string") { if (typeof storyResult === "string") {

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Success Stories")
h3.pb-3 Success Stories h3.pb-3 Success Stories
load-data(:load="retrieveStories") load-data(:load="retrieveStories")
table.table.table-sm.table-hover(v-if="stories?.length > 0") table.table.table-sm.table-hover(v-if="stories?.length > 0")

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Success Story")
load-data(:load="retrieveStory") load-data(:load="retrieveStory")
h3 h3
| {{citizenName}}&rsquo;s Success Story | {{citizenName}}&rsquo;s Success Story
@@ -31,7 +30,7 @@ const user = store.state.user as LogOnSuccess
/** The story to be displayed */ /** The story to be displayed */
const story : Ref<Success | undefined> = ref(undefined) const story : Ref<Success | undefined> = ref(undefined)
/** The citizen's name (real, display, or NAS, whichever is found first) */ /** The citizen's name (real, display, or Mastodon, whichever is found first) */
const citizenName = ref("") const citizenName = ref("")
/** Retrieve the success story */ /** Retrieve the success story */

View File

@@ -2,5 +2,14 @@ module.exports = {
transpileDependencies: [ transpileDependencies: [
'vuetify' 'vuetify'
], ],
outputDir: '../Api/wwwroot' outputDir: '../Server/wwwroot',
configureWebpack: {
module: {
rules: [{
test: /\.mjs$/,
include: /node_modules/,
type: "javascript/auto"
}]
}
}
} }

View File

@@ -1,121 +0,0 @@
namespace JobsJobsJobs.DataMigrate
/// Converters used to translate between database and domain types
module Converters =
open JobsJobsJobs.Shared
open Microsoft.EntityFrameworkCore.Storage.ValueConversion
/// Citizen ID converter
let CitizenIdConverter = ValueConverter<CitizenId, string> ((fun v -> string v), fun v -> CitizenId.Parse v)
/// Continent ID converter
let ContinentIdConverter = ValueConverter<ContinentId, string> ((fun v -> string v), fun v -> ContinentId.Parse v)
/// Markdown converter
let MarkdownStringConverter = ValueConverter<MarkdownString, string> ((fun v -> v.Text), fun v -> MarkdownString v)
/// Markdown converter for possibly-null values
let OptionalMarkdownStringConverter : ValueConverter<MarkdownString, string> =
ValueConverter<MarkdownString, string>
((fun v -> match v with null -> null | _ -> v.Text), fun v -> match v with null -> null | _ -> MarkdownString v)
/// Skill ID converter
let SkillIdConverter = ValueConverter<SkillId, string> ((fun v -> string v), fun v -> SkillId.Parse v)
/// Success ID converter
let SuccessIdConverter = ValueConverter<SuccessId, string> ((fun v -> string v), fun v -> SuccessId.Parse v)
open JobsJobsJobs.Shared
open Microsoft.EntityFrameworkCore
open System.Collections.Generic
/// Data context for Jobs, Jobs, Jobs
type JobsDbContext (options : DbContextOptions<JobsDbContext>) =
inherit DbContext (options)
/// Citizens (users known to us)
member val Citizens : DbSet<Citizen> = Unchecked.defaultof<DbSet<Citizen>> with get, set
/// Continents (large land masses - 7 of them!)
member val Continents : DbSet<Continent> = Unchecked.defaultof<DbSet<Continent>> with get, set
/// Employment profiles
member val Profiles : DbSet<Profile> = Unchecked.defaultof<DbSet<Profile>> with get, set
/// Skills held by citizens of Gitmo Nation
member val Skills : DbSet<Skill> = Unchecked.defaultof<DbSet<Skill>> with get, set
/// Success stories from the site
member val Successes : DbSet<Success> = Unchecked.defaultof<DbSet<Success>> with get, set
override _.OnModelCreating (mb : ModelBuilder) =
base.OnModelCreating(mb)
mb.Entity<Citizen>(fun m ->
m.ToTable("citizen", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter) |> ignore
m.Property(fun e -> e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50) |> ignore
m.Property(fun e -> e.DisplayName).HasColumnName("display_name").HasMaxLength(255) |> ignore
m.Property(fun e -> e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024) |> ignore
m.Property(fun e -> e.JoinedOn).HasColumnName("joined_on").IsRequired() |> ignore
m.Property(fun e -> e.LastSeenOn).HasColumnName("last_seen_on").IsRequired() |> ignore
m.Property(fun e -> e.RealName).HasColumnName("real_name").HasMaxLength(255) |> ignore
m.HasIndex(fun e -> e.NaUser :> obj).IsUnique() |> ignore
m.Ignore(fun e -> e.CitizenName :> obj) |> ignore)
|> ignore
mb.Entity<Continent>(fun m ->
m.ToTable("continent", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter) |> ignore
m.Property(fun e -> e.Name).HasColumnName("name").IsRequired().HasMaxLength(255) |> ignore)
|> ignore
mb.Entity<Profile>(fun m ->
m.ToTable("profile", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
m.Property(fun e -> e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter) |> ignore
m.Property(fun e -> e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired() |> ignore
m.Property(fun e -> e.IsPublic).HasColumnName("is_public").IsRequired() |> ignore
m.Property(fun e -> e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter) |> ignore
m.Property(fun e -> e.Region).HasColumnName("region").IsRequired().HasMaxLength(255) |> ignore
m.Property(fun e -> e.RemoteWork).HasColumnName("remote_work").IsRequired() |> ignore
m.Property(fun e -> e.FullTime).HasColumnName("full_time").IsRequired() |> ignore
m.Property(fun e -> e.Biography).HasColumnName("biography").IsRequired()
.HasConversion(Converters.MarkdownStringConverter) |> ignore
m.Property(fun e -> e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired() |> ignore
m.Property(fun e -> e.Experience).HasColumnName("experience")
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore
m.HasOne(fun e -> e.Continent :> obj)
.WithMany()
.HasForeignKey(fun e -> e.ContinentId :> obj) |> ignore
m.HasMany(fun e -> e.Skills :> IEnumerable<Skill>)
.WithOne()
.HasForeignKey(fun e -> e.CitizenId :> obj) |> ignore)
|> ignore
mb.Entity<Skill>(fun m ->
m.ToTable("skill", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SkillIdConverter) |> ignore
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter) |> ignore
m.Property(fun e -> e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100) |> ignore
m.Property(fun e -> e.Notes).HasColumnName("notes").HasMaxLength(100) |> ignore)
|> ignore
mb.Entity<Success>(fun m ->
m.ToTable("success", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SuccessIdConverter) |> ignore
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter) |> ignore
m.Property(fun e -> e.RecordedOn).HasColumnName("recorded_on").IsRequired() |> ignore
m.Property(fun e -> e.FromHere).HasColumnName("from_here").IsRequired() |> ignore
// m.Property(fun e -> e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
m.Property(fun e -> e.Story).HasColumnName("story")
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore)
|> ignore

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
<ItemGroup>
<Compile Include="DataContext.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\JobsJobsJobs.Shared.csproj" />
<ProjectReference Include="..\Api\Api.fsproj" />
<ProjectReference Include="..\Domain\Domain.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="5.0.7" />
<PackageReference Include="Npgsql" Version="5.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.7" />
<PackageReference Include="Npgsql.NodaTime" Version="5.0.7" />
</ItemGroup>
</Project>

View File

@@ -1,142 +0,0 @@
// Learn more about F# at http://docs.microsoft.com/dotnet/fsharp
open FSharp.Control.Tasks
open System
open System.Linq
open JobsJobsJobs.Api.Data
open JobsJobsJobs.Domain
open JobsJobsJobs.DataMigrate
open Microsoft.EntityFrameworkCore
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open RethinkDb.Driver.Net
/// Create the host (reads configuration and initializes both databases)
let createHostBuilder argv =
Host.CreateDefaultBuilder(argv)
.ConfigureServices(
Action<HostBuilderContext, IServiceCollection> (
fun hostCtx svcs ->
svcs.AddSingleton hostCtx.Configuration |> ignore
// PostgreSQL via EF Core
svcs.AddDbContext<JobsDbContext>(fun options ->
options.UseNpgsql(hostCtx.Configuration.["ConnectionStrings:JobsDb"],
fun o -> o.UseNodaTime() |> ignore)
|> ignore)
|> ignore
// RethinkDB
let cfg = hostCtx.Configuration.GetSection "Rethink"
let log = svcs.BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger "Data"
let conn = Startup.createConnection cfg log
svcs.AddSingleton conn |> ignore
Startup.establishEnvironment cfg log conn |> awaitIgnore
))
.Build()
[<EntryPoint>]
let main argv =
let host = createHostBuilder argv
let r = RethinkDb.Driver.RethinkDB.R
printfn "0) Connecting to databases..."
use db = host.Services.GetRequiredService<JobsDbContext> ()
let conn = host.Services.GetRequiredService<IConnection> ()
task {
printfn "1) Migrating continents..."
let mutable continentXref = Map.empty<string, Types.ContinentId>
let! continents = db.Continents.AsNoTracking().ToListAsync ()
let reContinents =
continents
|> Seq.map (fun c ->
let reContinentId = ContinentId.create ()
continentXref <- continentXref.Add (string c.Id, reContinentId)
let it : Types.Continent = {
id = reContinentId
name = c.Name
}
it)
|> List.ofSeq
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
printfn "2) Migrating citizens..."
let mutable citizenXref = Map.empty<string, Types.CitizenId>
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
let reCitizens =
citizens
|> Seq.map (fun c ->
let reCitizenId = CitizenId.create ()
citizenXref <- citizenXref.Add (string c.Id, reCitizenId)
let it : Types.Citizen = {
id = reCitizenId
naUser = c.NaUser
displayName = Option.ofObj c.DisplayName
realName = Option.ofObj c.RealName
profileUrl = c.ProfileUrl
joinedOn = c.JoinedOn
lastSeenOn = c.LastSeenOn
}
it)
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
printfn "3) Migrating profiles and skills..."
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
let reProfiles =
profiles
|> Seq.map (fun p ->
let skills = db.Skills.AsNoTracking().Where(fun s -> s.CitizenId = p.Id).ToList ()
let reSkills =
skills
|> Seq.map (fun skill ->
let it : Types.Skill = {
id = SkillId.create()
description = skill.Description
notes = Option.ofObj skill.Notes
}
it)
|> List.ofSeq
let it : Types.Profile = {
id = citizenXref.[string p.Id]
seekingEmployment = p.SeekingEmployment
isPublic = p.IsPublic
continentId = continentXref.[string p.ContinentId]
region = p.Region
remoteWork = p.RemoteWork
fullTime = p.FullTime
biography = Types.Text p.Biography.Text
lastUpdatedOn = p.LastUpdatedOn
experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text
skills = reSkills
}
it)
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
printfn "4) Migrating success stories..."
let! successes = db.Successes.AsNoTracking().ToListAsync ()
let reSuccesses =
successes
|> Seq.map (fun s ->
let it : Types.Success = {
id = SuccessId.create ()
citizenId = citizenXref.[string s.CitizenId]
recordedOn = s.RecordedOn
fromHere = s.FromHere
source = "profile"
story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text
}
it)
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
()
}
|> awaitIgnore
printfn "Migration complete"
0

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<WarnOn>3390;$(WarnOn)</WarnOn> <WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup> </PropertyGroup>
@@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.25.0" /> <PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="NodaTime" Version="3.0.5" /> <PackageReference Include="NodaTime" Version="3.0.5" />
</ItemGroup> </ItemGroup>

View File

@@ -33,7 +33,7 @@ module CitizenId =
module Citizen = module Citizen =
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in) /// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
let name x = let name x =
[ x.realName; x.displayName; Some x.naUser ] [ x.realName; x.displayName; Some x.mastodonUser ]
|> List.find Option.isSome |> List.find Option.isSome
|> Option.get |> Option.get

View File

@@ -2,6 +2,7 @@
module JobsJobsJobs.Domain.SharedTypes module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain.Types
open Microsoft.Extensions.Options
open NodaTime open NodaTime
// fsharplint:disable FieldNames // fsharplint:disable FieldNames
@@ -75,6 +76,45 @@ type Count = {
} }
/// 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
/// The authorization options for Jobs, Jobs, Jobs
type AuthOptions () =
/// The host for the return URL for Mastodoon 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
}
/// The fields required for a skill /// The fields required for a skill
type SkillForm = { type SkillForm = {
/// The ID of this skill /// The ID of this skill

View File

@@ -14,8 +14,10 @@ type CitizenId = CitizenId of Guid
type Citizen = { type Citizen = {
/// The ID of the user /// The ID of the user
id : CitizenId id : CitizenId
/// The Mastodon instance abbreviation from which this citizen is authorized
instance : string
/// The handle by which the user is known on Mastodon /// The handle by which the user is known on Mastodon
naUser : string mastodonUser : string
/// The user's display name from Mastodon (updated every login) /// The user's display name from Mastodon (updated every login)
displayName : string option displayName : string option
/// The user's real name /// The user's real name

View File

@@ -30,6 +30,7 @@ open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open Microsoft.IdentityModel.Tokens open Microsoft.IdentityModel.Tokens
open System.Text open System.Text
open JobsJobsJobs.Domain.SharedTypes
/// Configure dependency injection /// Configure dependency injection
let configureServices (svc : IServiceCollection) = let configureServices (svc : IServiceCollection) =
@@ -57,9 +58,10 @@ let configureServices (svc : IServiceCollection) =
ValidAudience = "https://noagendacareers.com", ValidAudience = "https://noagendacareers.com",
ValidIssuer = "https://noagendacareers.com", ValidIssuer = "https://noagendacareers.com",
IssuerSigningKey = SymmetricSecurityKey ( IssuerSigningKey = SymmetricSecurityKey (
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"])))) Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"])))
|> ignore |> ignore
svc.AddAuthorization () |> ignore svc.AddAuthorization () |> ignore
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore
let dbCfg = cfg.GetSection "Rethink" let dbCfg = cfg.GetSection "Rethink"
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup) let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)

View File

@@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth
open System.Text.Json.Serialization open System.Text.Json.Serialization
/// The variables we need from the account information we get from No Agenda Social /// The variables we need from the account information we get from Mastodon
[<NoComparison; NoEquality; AllowNullLiteral>] [<NoComparison; NoEquality; AllowNullLiteral>]
type MastodonAccount () = type MastodonAccount () =
/// The user name (what we store as naUser) /// The user name (what we store as mastodonUser)
[<JsonPropertyName "username">] [<JsonPropertyName "username">]
member val Username = "" with get, set member val Username = "" with get, set
/// The account name; will be the same as username for local (non-federated) accounts /// The account name; will generally be the same as username for local accounts, which is all we can verify
[<JsonPropertyName "acct">] [<JsonPropertyName "acct">]
member val AccountName = "" with get, set member val AccountName = "" with get, set
/// The user's display name as it currently shows on No Agenda Social /// The user's display name as it currently shows on Mastodon
[<JsonPropertyName "display_name">] [<JsonPropertyName "display_name">]
member val DisplayName = "" with get, set member val DisplayName = "" with get, set
/// The user's profile URL /// The user's profile URL
@@ -20,26 +20,32 @@ type MastodonAccount () =
member val Url = "" with get, set member val Url = "" with get, set
open FSharp.Control.Tasks
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open System open System
open System.Net.Http open System.Net.Http
open System.Net.Http.Headers open System.Net.Http.Headers
open System.Net.Http.Json open System.Net.Http.Json
open System.Text.Json open System.Text.Json
open JobsJobsJobs.Domain.SharedTypes
/// HTTP client to use to communication with Mastodon
let private http =
let h = new HttpClient ()
h.Timeout <- TimeSpan.FromSeconds 30.
h
/// Verify the authorization code with Mastodon and get the user's profile /// Verify the authorization code with Mastodon and get the user's profile
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task { let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task {
use http = new HttpClient() // Function to create a URL for the given instance
let apiUrl = sprintf "%s/api/v1/%s" inst.Url
// Use authorization code to get an access token from NAS // Use authorization code to get an access token from Mastodon
use! codeResult = use! codeResult =
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", http.PostAsJsonAsync($"{inst.Url}/oauth/token",
{| client_id = cfg.["ClientId"] {| client_id = inst.ClientId
client_secret = cfg.["Secret"] client_secret = inst.Secret
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"] redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
grant_type = "authorization_code" grant_type = "authorization_code"
code = authCode code = authCode
scope = "read" scope = "read"
@@ -49,11 +55,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync () let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes) use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
match tokenResponse with match tokenResponse with
| null -> | null -> return Error "Could not parse authorization code result"
return Error "Could not parse authorization code result"
| _ -> | _ ->
// Use access token to get profile from NAS // Use access token to get profile from NAS
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"]) use req = new HttpRequestMessage (HttpMethod.Get, apiUrl "accounts/verify_credentials")
req.Headers.Authorization <- AuthenticationHeaderValue req.Headers.Authorization <- AuthenticationHeaderValue
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ()) ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
use! profileResult = http.SendAsync req use! profileResult = http.SendAsync req
@@ -62,19 +67,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
| true -> | true ->
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync () let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
| null -> | null -> return Error "Could not parse profile result"
return Error "Could not parse profile result" | profile -> return Ok profile
| x when x.Username <> x.AccountName -> | false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
| profile ->
return Ok profile
| false ->
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
| false -> | false ->
let! err = codeResult.Content.ReadAsStringAsync () let! err = codeResult.Content.ReadAsStringAsync ()
log.LogError $"Could not get token result from Mastodon:\n {err}" log.LogError $"Could not get token result from Mastodon:\n {err}"
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})" return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
} }
@@ -86,7 +85,7 @@ open System.Security.Claims
open System.Text open System.Text
/// Create a JSON Web Token for this citizen to use for further requests to this API /// Create a JSON Web Token for this citizen to use for further requests to this API
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = let createJwt (citizen : Citizen) (cfg : AuthOptions) =
let tokenHandler = JwtSecurityTokenHandler () let tokenHandler = JwtSecurityTokenHandler ()
let token = let token =
@@ -100,8 +99,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
Issuer = "https://noagendacareers.com", Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com", Audience = "https://noagendacareers.com",
SigningCredentials = SigningCredentials ( SigningCredentials = SigningCredentials (
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]), SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
SecurityAlgorithms.HmacSha256Signature)
) )
) )
tokenHandler.WriteToken token tokenHandler.WriteToken token

View File

@@ -1,11 +1,11 @@
/// Data access functions for Jobs, Jobs, Jobs /// Data access functions for Jobs, Jobs, Jobs
module JobsJobsJobs.Api.Data module JobsJobsJobs.Api.Data
open FSharp.Control.Tasks
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain.Types
open Polly open Polly
open RethinkDb.Driver open RethinkDb.Driver
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open RethinkDb.Driver.Ast
/// Shorthand for the RethinkDB R variable (how every command starts) /// Shorthand for the RethinkDB R variable (how every command starts)
let private r = RethinkDB.R let private r = RethinkDB.R
@@ -166,10 +166,20 @@ module Startup =
log.LogInformation $"Creating \"{idx}\" index on {table}" log.LogInformation $"Creating \"{idx}\" index on {table}"
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore) r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
} }
do! ensureIndexes Table.Citizen [ "naUser" ]
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
do! ensureIndexes Table.Profile [ "continentId" ] do! ensureIndexes Table.Profile [ "continentId" ]
do! ensureIndexes Table.Success [ "citizenId" ] do! ensureIndexes Table.Success [ "citizenId" ]
// The instance/user is a compound index
let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync<string list> conn
match userIdx |> List.contains "instanceUser" with
| true -> ()
| false ->
let! _ =
r.Table(Table.Citizen)
.IndexCreate("instanceUser",
ReqlFunction1 (fun row -> upcast r.Array (row.G "instance", row.G "mastodonUser")))
.RunWriteAsync conn
()
} }
@@ -215,7 +225,6 @@ let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
open RethinkDb.Driver.Ast
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@@ -287,7 +296,7 @@ module Profile =
.HashMap("displayName", .HashMap("displayName",
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "naUser")) it.G "mastodonUser"))
.With ("citizenId", it.G "id"))) .With ("citizenId", it.G "id")))
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn") .Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ())) .OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
@@ -348,12 +357,16 @@ module Citizen =
.RunResultAsync<Citizen> .RunResultAsync<Citizen>
|> withReconnOption conn |> withReconnOption conn
/// Find a citizen by their No Agenda Social username /// Find a citizen by their Mastodon username
let findByNaUser (naUser : string) conn = let findByMastodonUser (instance : string) (mastodonUser : string) conn =
fun c -> task {
let! u =
r.Table(Table.Citizen) r.Table(Table.Citizen)
.GetAll(naUser).OptArg("index", "naUser").Nth(0) .GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Limit(1)
.RunResultAsync<Citizen> .RunResultAsync<Citizen list> c
|> withReconnOption conn return u |> List.tryHead
}
|> withReconn conn
/// Add a citizen /// Add a citizen
let add (citizen : Citizen) conn = let add (citizen : Citizen) conn =
@@ -546,7 +559,7 @@ module Success =
.HashMap("citizenName", .HashMap("citizenName",
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "naUser")) it.G "mastodonUser"))
.With ("hasStory", it.G("story").Default_("").Gt ""))) .With ("hasStory", it.G("story").Default_("").Gt "")))
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") .Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
.OrderBy(r.Desc "recordedOn") .OrderBy(r.Desc "recordedOn")

View File

@@ -1,7 +1,6 @@
/// Route handlers for Giraffe endpoints /// Route handlers for Giraffe endpoints
module JobsJobsJobs.Api.Handlers module JobsJobsJobs.Api.Handlers
open FSharp.Control.Tasks
open Giraffe open Giraffe
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
@@ -23,7 +22,7 @@ module Error =
/// URL prefixes for the Vue app /// URL prefixes for the Vue app
let vueUrls = [ let vueUrls = [
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
"/so-long"; "/success-story" "/so-long"; "/success-story"
] ]
@@ -31,15 +30,15 @@ module Error =
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
let fac = ctx.GetService<ILoggerFactory> () let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger("Handler") let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) -> | true when path = "/" || vueUrls |> List.exists path.StartsWith ->
log.LogInformation "Returning Vue app" log.LogInformation "Returning Vue app"
return! Vue.app next ctx return! Vue.app next ctx
| _ -> | _ ->
log.LogInformation "Returning 404" log.LogInformation "Returning 404"
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
ctx
} }
/// Handler that returns a 403 NOT AUTHORIZED response /// Handler that returns a 403 NOT AUTHORIZED response
@@ -58,6 +57,7 @@ module Helpers =
open NodaTime open NodaTime
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Options
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open System.Security.Claims open System.Security.Claims
@@ -67,6 +67,9 @@ module Helpers =
/// Get the application configuration from the request context /// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> () let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the authorization configuration from the request context
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
/// Get the logger factory from the request context /// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> () let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
@@ -104,23 +107,27 @@ module Helpers =
module Citizen = module Citizen =
// GET: /api/citizen/log-on/[code] // GET: /api/citizen/log-on/[code]
let logOn authCode : HttpHandler = let logOn (abbr, authCode) : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
// Step 1 - Verify with Mastodon // Step 1 - Verify with Mastodon
let cfg = (config ctx).GetSection "Auth" let cfg = authConfig ctx
match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
| Some instance ->
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
match! Auth.verifyWithMastodon authCode cfg log with match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with
| Ok account -> | Ok account ->
// Step 2 - Find / establish Jobs, Jobs, Jobs account // Step 2 - Find / establish Jobs, Jobs, Jobs account
let now = (clock ctx).GetCurrentInstant () let now = (clock ctx).GetCurrentInstant ()
let dbConn = conn ctx let dbConn = conn ctx
let! citizen = task { let! citizen = task {
match! Data.Citizen.findByNaUser account.Username dbConn with match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with
| None -> | None ->
let it : Citizen = let it : Citizen =
{ id = CitizenId.create () { id = CitizenId.create ()
naUser = account.Username instance = instance.Abbr
mastodonUser = account.Username
displayName = noneIfEmpty account.DisplayName displayName = noneIfEmpty account.DisplayName
realName = None realName = None
profileUrl = account.Url profileUrl = account.Url
@@ -142,8 +149,8 @@ module Citizen =
citizenId = CitizenId.toString citizen.id citizenId = CitizenId.toString citizen.id
name = Citizen.name citizen name = Citizen.name citizen
} next ctx } next ctx
| Error err -> | Error err -> return! RequestErrors.BAD_REQUEST err next ctx
return! RequestErrors.BAD_REQUEST err next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/citizen/[id] // GET: /api/citizen/[id]
@@ -176,6 +183,25 @@ module Continent =
} }
/// Handlers for /api/instances routes
[<RequireQualifiedAccess>]
module Instances =
/// Convert a Masotodon instance to the one we use in the API
let private toInstance (inst : MastodonInstance) =
{ name = inst.Name
url = inst.Url
abbr = inst.Abbr
clientId = inst.ClientId
}
// GET: /api/instances
let all : HttpHandler =
fun next ctx -> task {
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
}
/// Handlers for /api/listing[s] routes /// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
@@ -261,7 +287,7 @@ module Listing =
// PATCH: /api/listing/[id] // PATCH: /api/listing/[id]
let expire listingId : HttpHandler = let expire listingId : HttpHandler =
authorize authorize
>=> fun next ctx -> task { >=> fun next ctx -> FSharp.Control.Tasks.Affine.task {
let dbConn = conn ctx let dbConn = conn ctx
let now = clock(ctx).GetCurrentInstant () let now = clock(ctx).GetCurrentInstant ()
match! Data.Listing.findById (ListingId listingId) dbConn with match! Data.Listing.findById (ListingId listingId) dbConn with
@@ -282,7 +308,6 @@ module Listing =
| None -> () | None -> ()
return! ok next ctx return! ok next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/listing/search // GET: /api/listing/search
@@ -489,12 +514,13 @@ let allEndpoints = [
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ GET_HEAD [
routef "/log-on/%s" Citizen.logOn routef "/log-on/%s/%s" Citizen.logOn
routef "/%O" Citizen.get routef "/%O" Citizen.get
] ]
DELETE [ route "" Citizen.delete ] DELETE [ route "" Citizen.delete ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]
GET_HEAD [ route "/instances" Instances.all ]
subRoute "/listing" [ subRoute "/listing" [
GET_HEAD [ GET_HEAD [
routef "/%O" Listing.get routef "/%O" Listing.get

View File

@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<WarnOn>3390;$(WarnOn)</WarnOn> <WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup> </PropertyGroup>
@@ -14,7 +14,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Domain\Domain.fsproj" /> <ProjectReference Include="..\Domain\JobsJobsJobs.Domain.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,22 @@
{
"Auth": {
"ReturnHost": "http://localhost:5000",
"Instances": {
"0": {
"Name": "No Agenda Social",
"Url": "https://noagendasocial.com",
"Abbr": "nas"
},
"1": {
"Name": "ITM Slaves!",
"Url": "https://itmslaves.com",
"Abbr": "itm"
},
"2": {
"Name": "Liberty Woof",
"Url": "https://libertywoof.com",
"Abbr": "lw"
}
}
}
}

View File

@@ -1,7 +0,0 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// A transport mechanism to send counts across the wire via JSON
/// </summary>
public record Count(int Value);
}

View File

@@ -1,13 +0,0 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// A successful log on; returns JWT, citizen ID, and display name
/// </summary>
public record LogOnSuccess(string Jwt, string Id, string Name)
{
/// <summary>
/// The ID return value as a citizen ID
/// </summary>
public CitizenId CitizenId => CitizenId.Parse(Id);
}
}

View File

@@ -1,93 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The data required to update a profile
/// </summary>
public class ProfileForm
{
/// <summary>
/// Whether the citizen to whom this profile belongs is actively seeking employment
/// </summary>
public bool IsSeekingEmployment { get; set; }
/// <summary>
/// Whether this profile should appear in the public search
/// </summary>
public bool IsPublic { get; set; }
/// <summary>
/// The user's real name
/// </summary>
[StringLength(255)]
public string RealName { get; set; } = "";
/// <summary>
/// The ID of the continent on which the citizen is located
/// </summary>
[Required]
[StringLength(12, MinimumLength = 1)]
[Display(Name = "Continent")]
public string ContinentId { get; set; } = "";
/// <summary>
/// The area within that continent where the citizen is located
/// </summary>
[Required]
[StringLength(255)]
public string Region { get; set; } = "";
/// <summary>
/// If the citizen is available for remote work
/// </summary>
public bool RemoteWork { get; set; }
/// <summary>
/// If the citizen is seeking full-time employment
/// </summary>
public bool FullTime { get; set; }
/// <summary>
/// The user's professional biography
/// </summary>
[Required]
public string Biography { get; set; } = "";
/// <summary>
/// The user's past experience
/// </summary>
public string Experience { get; set; } = "";
/// <summary>
/// The skills for the user
/// </summary>
public ICollection<SkillForm> Skills { get; set; } = new List<SkillForm>();
/// <summary>
/// Create an instance of this form from the given profile
/// </summary>
/// <param name="profile">The profile off which this form will be based</param>
/// <returns>The profile form, popluated with values from the given profile</returns>
public static ProfileForm FromProfile(Profile profile) =>
new ProfileForm
{
IsSeekingEmployment = profile.SeekingEmployment,
IsPublic = profile.IsPublic,
ContinentId = profile.ContinentId.ToString(),
Region = profile.Region,
RemoteWork = profile.RemoteWork,
FullTime = profile.FullTime,
Biography = profile.Biography.Text,
Experience = profile.Experience?.Text ?? "",
Skills = profile.Skills.Select(s => new SkillForm
{
Id = s.Id.ToString(),
Description = s.Description,
Notes = s.Notes
}).ToList()
};
}
}

View File

@@ -1,37 +0,0 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The various ways profiles can be searched
/// </summary>
public class ProfileSearch
{
/// <summary>
/// Retrieve citizens from this continent
/// </summary>
public string? ContinentId { get; set; }
/// <summary>
/// Text for a search within a citizen's skills
/// </summary>
public string? Skill { get; set; }
/// <summary>
/// Text for a search with a citizen's professional biography and experience fields
/// </summary>
public string? BioExperience { get; set; }
/// <summary>
/// Whether to retrieve citizens who do or do not want remote work
/// </summary>
public string RemoteWork { get; set; } = "";
/// <summary>
/// Is the search empty?
/// </summary>
public bool IsEmptySearch =>
string.IsNullOrEmpty(ContinentId)
&& string.IsNullOrEmpty(Skill)
&& string.IsNullOrEmpty(BioExperience)
&& string.IsNullOrEmpty(RemoteWork);
}
}

View File

@@ -1,15 +0,0 @@
using NodaTime;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// A user matching the profile search
/// </summary>
public record ProfileSearchResult(
CitizenId CitizenId,
string DisplayName,
bool SeekingEmployment,
bool RemoteWork,
bool FullTime,
Instant LastUpdated);
}

View File

@@ -1,37 +0,0 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The parameters for a public job search
/// </summary>
public class PublicSearch
{
/// <summary>
/// Retrieve citizens from this continent
/// </summary>
public string? ContinentId { get; set; }
/// <summary>
/// Retrieve citizens from this region
/// </summary>
public string? Region { get; set; }
/// <summary>
/// Text for a search within a citizen's skills
/// </summary>
public string? Skill { get; set; }
/// <summary>
/// Whether to retrieve citizens who do or do not want remote work
/// </summary>
public string RemoteWork { get; set; } = "";
/// <summary>
/// Is the search empty?
/// </summary>
public bool IsEmptySearch =>
string.IsNullOrEmpty(ContinentId)
&& string.IsNullOrEmpty(Region)
&& string.IsNullOrEmpty(Skill)
&& string.IsNullOrEmpty(RemoteWork);
}
}

View File

@@ -1,13 +0,0 @@
using System.Collections.Generic;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// A public profile search result
/// </summary>
public record PublicSearchResult(
string Continent,
string Region,
bool RemoteWork,
IEnumerable<string> Skills);
}

View File

@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The fields required for a skill
/// </summary>
public class SkillForm
{
/// <summary>
/// The ID of this skill
/// </summary>
[Required]
public string Id { get; set; } = "";
/// <summary>
/// The description of the skill
/// </summary>
[StringLength(100)]
public string Description { get; set; } = "";
/// <summary>
/// Notes regarding the skill
/// </summary>
[StringLength(100)]
public string? Notes { get; set; } = null;
}
}

View File

@@ -1,15 +0,0 @@
using NodaTime;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// An entry in the list of success stories
/// </summary>
public record StoryEntry(
SuccessId Id,
CitizenId CitizenId,
string CitizenName,
Instant RecordedOn,
bool FromHere,
bool HasStory);
}

View File

@@ -1,23 +0,0 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The data required to provide a success story
/// </summary>
public class StoryForm
{
/// <summary>
/// The ID of this story
/// </summary>
public string Id { get; set; } = "new";
/// <summary>
/// Whether the employment was obtained from Jobs, Jobs, Jobs
/// </summary>
public bool FromHere { get; set; } = false;
/// <summary>
/// The success story
/// </summary>
public string Story { get; set; } = "";
}
}

View File

@@ -1,22 +0,0 @@
using NodaTime;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A user of Jobs, Jobs, Jobs
/// </summary>
public record Citizen(
CitizenId Id,
string NaUser,
string? DisplayName,
string? RealName,
string ProfileUrl,
Instant JoinedOn,
Instant LastSeenOn)
{
/// <summary>
/// The user's name by which they should be known
/// </summary>
public string CitizenName => RealName ?? DisplayName ?? NaUser;
}
}

View File

@@ -1,7 +0,0 @@
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A continent
/// </summary>
public record Continent(ContinentId Id, string Name);
}

View File

@@ -1,148 +0,0 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A short ID
/// </summary>
public record ShortId(string Id)
{
/// <summary>
/// Validate the format of the short ID
/// </summary>
private static readonly Regex ValidShortId =
new Regex("^[a-z0-9_-]{12}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Create a new short ID
/// </summary>
/// <returns>A new short ID</returns>
public static async Task<ShortId> Create() => new ShortId(await Nanoid.Nanoid.GenerateAsync(size: 12));
/// <summary>
/// Try to parse a string of text into a short ID
/// </summary>
/// <param name="text">The text of the prospective short ID</param>
/// <returns>The short ID</returns>
/// <exception cref="FormatException">If the format is not valid</exception>
public static ShortId Parse(string text)
{
if (text.Length == 12 && ValidShortId.IsMatch(text)) return new ShortId(text);
throw new FormatException($"The string {text} is not a valid short ID");
}
public override string ToString() => Id;
}
/// <summary>
/// The ID of a user (a citizen of Gitmo Nation)
/// </summary>
public record CitizenId(ShortId Id)
{
/// <summary>
/// Create a new citizen ID
/// </summary>
/// <returns>A new citizen ID</returns>
public static async Task<CitizenId> Create() => new CitizenId(await ShortId.Create());
/// <summary>
/// Attempt to create a citizen ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The citizen ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid citizen ID</exception>
public static CitizenId Parse(string id) => new(ShortId.Parse(id));
public override string ToString() => Id.ToString();
}
/// <summary>
/// The ID of a continent
/// </summary>
public record ContinentId(ShortId Id)
{
/// <summary>
/// Create a new continent ID
/// </summary>
/// <returns>A new continent ID</returns>
public static async Task<ContinentId> Create() => new ContinentId(await ShortId.Create());
/// <summary>
/// Attempt to create a continent ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The continent ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid continent ID</exception>
public static ContinentId Parse(string id) => new(ShortId.Parse(id));
public override string ToString() => Id.ToString();
}
/// <summary>
/// The ID of a job listing
/// </summary>
public record ListingId(ShortId Id)
{
/// <summary>
/// Create a new job listing ID
/// </summary>
/// <returns>A new job listing ID</returns>
public static async Task<ListingId> Create() => new ListingId(await ShortId.Create());
/// <summary>
/// Attempt to create a job listing ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The job listing ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid job listing ID</exception>
public static ListingId Parse(string id) => new(ShortId.Parse(id));
public override string ToString() => Id.ToString();
}
/// <summary>
/// The ID of a skill
/// </summary>
public record SkillId(ShortId Id)
{
/// <summary>
/// Create a new skill ID
/// </summary>
/// <returns>A new skill ID</returns>
public static async Task<SkillId> Create() => new SkillId(await ShortId.Create());
/// <summary>
/// Attempt to create a skill ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The skill ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid skill ID</exception>
public static SkillId Parse(string id) => new(ShortId.Parse(id));
public override string ToString() => Id.ToString();
}
/// <summary>
/// The ID of a success report
/// </summary>
public record SuccessId(ShortId Id)
{
/// <summary>
/// Create a new success report ID
/// </summary>
/// <returns>A new success report ID</returns>
public static async Task<SuccessId> Create() => new SuccessId(await ShortId.Create());
/// <summary>
/// Attempt to create a success report ID from a string
/// </summary>
/// <param name="id">The prospective ID</param>
/// <returns>The success report ID</returns>
/// <exception cref="System.FormatException">If the string is not a valid success report ID</exception>
public static SuccessId Parse(string id) => new(ShortId.Parse(id));
public override string ToString() => Id.ToString();
}
}

View File

@@ -1,33 +0,0 @@
using NodaTime;
using System;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A job listing
/// </summary>
public record Listing(
ListingId Id,
CitizenId CitizenId,
Instant CreatedOn,
string Title,
ContinentId ContinentId,
string Region,
bool RemoteWork,
bool IsExpired,
Instant UpdatedOn,
MarkdownString Text,
LocalDate? NeededBy,
bool? WasFilledHere)
{
/// <summary>
/// Navigation property for the citizen who created the job listing
/// </summary>
public Citizen? Citizen { get; set; }
/// <summary>
/// Navigation property for the continent
/// </summary>
public Continent? Continent { get; set; }
}
}

View File

@@ -1,21 +0,0 @@
using Markdig;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A string of Markdown text
/// </summary>
public record MarkdownString(string Text)
{
/// <summary>
/// The Markdown conversion pipeline (enables all advanced features)
/// </summary>
private readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
/// <summary>
/// Convert this Markdown string to HTML
/// </summary>
/// <returns>This Markdown string as HTML</returns>
public string ToHtml() => Markdown.ToHtml(Text, Pipeline);
}
}

View File

@@ -1,31 +0,0 @@
using NodaTime;
using System.Collections.Generic;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A job seeker profile
/// </summary>
public record Profile(
CitizenId Id,
bool SeekingEmployment,
bool IsPublic,
ContinentId ContinentId,
string Region,
bool RemoteWork,
bool FullTime,
MarkdownString Biography,
Instant LastUpdatedOn,
MarkdownString? Experience)
{
/// <summary>
/// Navigation property for continent
/// </summary>
public Continent? Continent { get; set; }
/// <summary>
/// Convenience property for skills associated with a profile
/// </summary>
public ICollection<Skill> Skills { get; set; } = new List<Skill>();
}
}

View File

@@ -1,7 +0,0 @@
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A skill the job seeker possesses
/// </summary>
public record Skill(SkillId Id, CitizenId CitizenId, string Description, string? Notes);
}

View File

@@ -1,15 +0,0 @@
using NodaTime;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A record of success finding employment
/// </summary>
public record Success(
SuccessId Id,
CitizenId CitizenId,
Instant RecordedOn,
bool FromHere,
// string Source,
MarkdownString? Story);
}

View File

@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Markdig" Version="0.22.1" />
<PackageReference Include="Nanoid" Version="2.1.0" />
<PackageReference Include="NodaTime" Version="3.0.3" />
</ItemGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
</Project>

View File

@@ -1,85 +0,0 @@
using System;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A result with two different possibilities
/// </summary>
/// <typeparam name="TOk">The type of the Ok result</typeparam>
public struct Result<TOk>
{
private readonly TOk? _okValue;
/// <summary>
/// Is this an Ok result?
/// </summary>
public bool IsOk { get; init; }
/// <summary>
/// Is this an Error result?
/// </summary>
public bool IsError
{
get => !IsOk;
}
/// <summary>
/// The Ok value
/// </summary>
public TOk Ok
{
get => _okValue!;
}
/// <summary>
/// The error value
/// </summary>
public string Error { get; set; }
/// <summary>
/// Constructor (inaccessible - use static creation methods)
/// </summary>
/// <param name="isOk">Whether this is an Ok result</param>
/// <param name="okValue">The value of the Ok result</param>
/// <param name="error">The error message of the Error result</param>
private Result(bool isOk, TOk? okValue = default, string error = "")
{
IsOk = isOk;
_okValue = okValue;
Error = error;
}
/// <summary>
/// Create an Ok result
/// </summary>
/// <param name="okValue">The value of the Ok result</param>
/// <returns>The Ok result</returns>
public static Result<TOk> AsOk(TOk okValue) => new Result<TOk>(true, okValue);
/// <summary>
/// Create an Error result
/// </summary>
/// <param name="error">The error message</param>
/// <returns>The Error result</returns>
public static Result<TOk> AsError(string error) => new Result<TOk>(false) { Error = error };
/// <summary>
/// Transform a result if it is OK, passing the error along if it is an error
/// </summary>
/// <param name="f">The transforming function</param>
/// <param name="result">The existing result</param>
/// <returns>The resultant result</returns>
public static Result<TOk> Bind(Func<TOk, Result<TOk>> f, Result<TOk> result) =>
result.IsOk ? f(result.Ok) : result;
/// <summary>
/// Transform a result to a different type if it is OK, passing the error along if it is an error
/// </summary>
/// <typeparam name="TOther">The type to which the result is transformed</typeparam>
/// <param name="f">The transforming function</param>
/// <param name="result">The existing result</param>
/// <returns>The resultant result</returns>
public static Result<TOther> Map<TOther>(Func<TOk, Result<TOther>> f, Result<TOk> result) =>
result.IsOk ? f(result.Ok) : Result<TOther>.AsError(result.Error);
}
}