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/**/appsettings.*.json
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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
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
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject
Global
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -47,6 +41,5 @@ Global
GlobalSection(NestedProjects) = preSolution
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {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
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",
"version": "2.0.0",
"version": "2.2.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"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": {
"@mdi/js": "^5.9.55",
"@vuelidate/core": "^2.0.0-alpha.24",
"@vuelidate/validators": "^2.0.0-alpha.21",
"@vueuse/core": "^6.3.3",
"bootstrap": "^5.1.0",
"core-js": "^3.16.3",
"date-fns": "^2.23.0",

View File

@@ -10,11 +10,12 @@
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { defineComponent, onMounted } from "vue"
import "bootstrap/dist/css/bootstrap.min.css"
import { Citizen } from "./api"
import { Mutations, useStore } from "./store"
import AppFooter from "./components/layout/AppFooter.vue"
import AppNav from "./components/layout/AppNav.vue"
import AppToaster from "./components/layout/AppToaster.vue"
@@ -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
*
@@ -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
* @returns The citizen's display name
*/
export function citizenName (cit : Citizen) : string {
return cit.realName ?? cit.displayName ?? cit.naUser
return cit.realName ?? cit.displayName ?? cit.mastodonUser
}
</script>

View File

@@ -2,6 +2,7 @@ import {
Citizen,
Continent,
Count,
Instance,
Listing,
ListingExpireForm,
ListingForm,
@@ -25,7 +26,7 @@ import {
* @param url The partial 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
@@ -100,11 +101,12 @@ export default {
/**
* 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
*/
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}`
},
@@ -141,6 +143,18 @@ export default {
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
},
/** API functions for instances */
instances: {
/**
* Get all Mastodon instances we support
*
* @returns All instances, or an error
*/
all: async () : Promise<Instance[] | string | undefined> =>
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
},
/** API functions for job listings */
listings: {

View File

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

View File

@@ -1,15 +1,15 @@
<template lang="pug">
form.container
.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")
.col.col-xs-12.col-sm-6.col-lg-3
.col-xs-12.col-sm-6.col-lg-3
.form-floating
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
@input="updateValue('region', $event.target.value)")
label(for="regionSearch") Region
.form-text (free-form text)
.col.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
@input="updateValue('text', $event.target.value)")
label(for="textSearch") Job Listing Text
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</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">
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 &nbsp;
nav
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
app-links
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useStore } from "@/store"
import {
mdiHelpCircleOutline,
mdiHome,
mdiLoginVariant,
mdiLogoutVariant,
mdiNewspaperVariantMultipleOutline,
mdiPencil,
mdiSignText,
mdiThumbUp,
mdiViewDashboardVariant,
mdiViewListOutline
} from "@mdi/js"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import AppLinks from "./AppLinks.vue"
const store = useStore()
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether a user is logged in or not */
const isLoggedOn = computed(() => store.state.user !== undefined)
/** Whether the mobile menu or the desktop menu should be shown */
const showMobileMenu = breakpoints.smaller("md")
</script>
<style lang="sass" scoped>
aside
aside,
#mobileMenu
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
color: white
font-size: 1.2rem
aside
min-height: 100vh
width: 250px
min-width: 250px
position: sticky
top: 0
path
fill: white
path:hover
fill: black
a:link, a:visited
text-decoration: none
color: white
// font-weight: 500
.home-link
font-size: 1.2rem
text-align: center
background-color: rgba(0, 0, 0, .4)
margin: -1rem
padding: 1rem
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
a:link,
a:visited
text-decoration: none
nav > div.separator
border-bottom: solid 1px rgba(255, 255, 255, .75)
height: 1px
color: white
</style>

View File

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

View File

@@ -1,15 +1,15 @@
<template lang="pug">
form.container
.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")
.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
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
:value="criteria.region" @input="updateValue('region', $event.target.value)")
label(for="region") Region
.form-text (free-form text)
.col.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template>

View File

@@ -1,9 +1,9 @@
<template lang="pug">
form.container
.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")
.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
@input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill
.form-text (free-form text)
.col.col-xs-12.col-sm-6.col-lg-3
.col-xs-12.col-sm-6.col-lg-3
.form-floating
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
@input="updateValue('bioExperience', $event.target.value)")
label(for="bioSearch") Bio / Experience
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template>

View File

@@ -1,15 +1,15 @@
<template lang="pug">
.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;
.col.col-xs-10.col-md-6
.col-xs-10.col-md-6
.form-floating
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
@input="updateValue('description', $event.target.value)")
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
.form-text A skill (language, design technique, process, etc.)
.col.col-xs-12.col-md-5
.col-xs-12.col-md-5
.form-floating
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"

View File

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

View File

@@ -3,14 +3,13 @@ import {
createWebHistory,
RouteLocationNormalized,
RouteLocationNormalizedLoaded,
RouteRecordName,
RouteRecordRaw
} from "vue-router"
import store from "@/store"
import store, { Mutations } from "@/store"
import Home from "@/views/Home.vue"
import LogOn from "@/views/citizen/LogOn.vue"
/** The URL to which the user should be pointed once they have authorized with 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"
/**
@@ -29,124 +28,141 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home
component: Home,
meta: { title: "Welcome!" }
},
{
path: "/how-it-works",
name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue")
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" }
},
{
path: "/privacy-policy",
name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue")
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" }
},
{
path: "/terms-of-service",
name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue")
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" }
},
// Citizen URLs
{
path: "/citizen/log-on",
name: "LogOn",
component: LogOn
component: LogOn,
meta: { title: "Log On" }
},
{
path: "/citizen/authorized",
path: "/citizen/:abbr/authorized",
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",
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",
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",
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
{
path: "/help-wanted",
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",
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",
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",
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",
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
{
path: "/profile/:id/view",
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",
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",
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
{
path: "/so-long/options",
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",
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
{
path: "/success-story/list",
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",
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",
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({
history: createWebHistory(process.env.BASE_URL),
@@ -159,10 +175,11 @@ const router = createRouter({
// eslint-disable-next-line
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)
return "/citizen/log-on"
}
store.commit(Mutations.SetTitle, to.meta.title ?? "")
})
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 { 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 */
export interface State {
/** The document's current title */
pageTitle : string
/** The currently logged-on user */
user : LogOnSuccess | undefined
/** The state of the log on process */
logOnState : string
/** All continents (use `ensureContinents` action) */
continents : Continent[]
/** All instances (use `ensureInstances` action) */
instances : Instance[]
}
/** An injection key to identify this state with Vue */
@@ -20,47 +27,63 @@ export function useStore () : Store<State> {
return baseUseStore(key)
}
/** The application name */
const appName = "Jobs, Jobs, Jobs"
export default createStore({
state: () : State => {
return {
pageTitle: "",
user: undefined,
logOnState: "<em>Welcome back! Verifying your No Agenda Social account&hellip;</em>",
continents: []
logOnState: "<em>Welcome back!</em>",
continents: [],
instances: []
}
},
mutations: {
setUser (state, user : LogOnSuccess) {
state.user = user
[Mutations.SetTitle]: (state, title : string) => {
state.pageTitle = title === "" ? appName : `${title} | ${appName}`
useTitle(state.pageTitle)
},
clearUser (state) {
state.user = undefined
},
setLogOnState (state, message : string) {
state.logOnState = message
},
setContinents (state, continents : Continent[]) {
state.continents = continents
}
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
[Mutations.ClearUser]: (state) => { state.user = undefined },
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
[Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
},
actions: {
async logOn ({ commit }, code: string) {
const logOnResult = await api.citizen.logOn(code)
[Actions.LogOn]: async ({ commit }, { abbr, code }) => {
const logOnResult = await api.citizen.logOn(abbr, code)
if (typeof logOnResult === "string") {
commit("setLogOnState", logOnResult)
commit(Mutations.SetLogOnState, logOnResult)
} else {
commit("setUser", logOnResult)
commit(Mutations.SetUser, logOnResult)
}
},
async ensureContinents ({ state, commit }) {
[Actions.EnsureContinents]: async ({ state, commit }) => {
if (state.continents.length > 0) return
const theSeven = await api.continent.all()
if (typeof theSeven === "string") {
console.error(theSeven)
} else {
commit("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: {
}
})
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">
article
page-title(title="Welcome!")
p &nbsp;
p.
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">
article
page-title(title="How It Works")
h3 How It Works
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
p: em.
@@ -21,8 +20,8 @@ article
p.
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
of the information from the search results, along with the citizen who posted it, and the full details of the job.
The citizen&rsquo;s name is a link to their profile page at No Agenda Social; you can use that to get their handle,
and use NAS&rsquo;s communication facilites to inquire about the position.
The citizen&rsquo;s name is a link to their profile page at their Mastodon instance; you can use that to get their
handle, and use Mastodon&rsquo;s communication facilites to inquire about the position.
p: em.text-muted.
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
I&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
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s access, but then they
will get there as well.)
the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s
access, but then they will get there as well.)
h5 Expire a Job Listing
p.
@@ -68,7 +67,7 @@ article
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
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
@@ -76,8 +75,8 @@ article
p.
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,
profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current
display name each time you log on.
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
current display name each time you log on.
h5 Completing Your Profile
p.
@@ -99,19 +98,19 @@ article
li.
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
employment, your continent, region, and skills fields will be searchable and displayed to public users of the
site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek
behind the curtain a bit, and hopefully inspire them to join us.
site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
curtain a bit, and hopefully inspire them to join us.
h5 Viewing and Sharing Your Profile
p.
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
that will let you view your profile the way all other validated users will be able to see it. (There will also be a
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you
would like to share it there. Just as with job listings, existing users will go straight there, while other No
Agenda Social users will get there once they authorize this application.
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
there, while others will get there once they authorize this application.
p.
The name on employment profiles is a link to that user&rsquo;s profile on No Agenda Social; from there, others can
communicate further with you using the tools Mastodon provides.
The name on employment profiles is a link to that user&rsquo;s profile on their Mastodon instance; from there,
others can communicate further with you using the tools Mastodon provides.
h5 &ldquo;I Found a Job!&rdquo;
p.

View File

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

View File

@@ -1,8 +1,7 @@
<template lang="pug">
article
page-title(title="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
p.
@@ -11,12 +10,19 @@ article
acceptance of these terms.
h4 Description of Service and Registration
p.
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
to the details of these profiles to other users of
#[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
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain.
p
| Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
| to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
= " "
template(v-for="(it, idx) in instances" :key="idx")
a(:href="it.url" target="_blank") {{it.name}}
template(v-if="idx + 2 < instances.length")= ", "
template(v-else-if="idx + 1 < instances.length")= ", and "
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one&rsquo;s Mastodon profile. See our
= " "
router-link(to="/privacy-policy") privacy policy
= " "
| for details on the personal (user) information we maintain.
h4 Liability
p.
@@ -34,4 +40,23 @@ article
p.
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
data.
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with a list of all No
Agenda-affiliated Mastodon instances.
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useStore, Actions } from "@/store"
const store = useStore()
/** All instances authorized to view Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,50 @@
<template lang="pug">
article
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>
<script setup lang="ts">
/**
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
* can be called from two different places, and allow the app to support direct links to authorized content.
*/
import { computed, onMounted, Ref, ref } from "vue"
import { Instance } from "@/api"
import { useStore, Actions } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The instances configured for Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
/** Whether authorization is in progress */
const selected : Ref<Instance | undefined> = ref(undefined)
/** The authorization URL to which the user should be directed */
const authUrl = (() => {
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
const client = `client_id=${id}`
const authUrl = computed(() => {
if (selected.value) {
const client = `client_id=${selected.value.clientId}`
const scope = "scope=read:accounts"
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized`
const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
const respType = "response_type=code"
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
})()
document.location.assign(authUrl)
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
}
return ""
})
/**
* Select a given Mastodon instance
*
* @param abbr The abbreviation of the instance being selected
*/
const select = (abbr : string) => {
selected.value = instances.value.find(it => it.abbr === abbr)
document.location.assign(authUrl.value)
}
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Help Wanted")
h3.pb-3 Help Wanted
p(v-if="!searched").
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">
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-else) Edit Job Listing
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 { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import ContinentList from "@/components/ContinentList.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) */
const retrieveData = async (errors : string[]) => {
if (isNew.value) store.commit(Mutations.SetTitle, "Add a Job Listing")
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
if (typeof listResult === "string") {
errors.push(listResult)

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="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
load-data(:load="getListings")

View File

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

View File

@@ -34,7 +34,7 @@ import { mdiPencil } from "@mdi/js"
import api, { LogOnSuccess, ProfileForView } from "@/api"
import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
@@ -66,14 +66,10 @@ const retrieveProfile = async (errors : string[]) => {
errors.push("Profile not found")
} else {
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 */
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))

View File

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

View File

@@ -1,11 +1,28 @@
<template lang="pug">
article
page-title(title="Account Deletion Success")
h3.pb-3 Account Deletion Success
p.
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
application, find it in #[a(href="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
account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { useStore, Actions } from "@/store"
const route = useRoute()
const store = useStore()
/** The abbreviation of the instance from which the deleted user had authorized access */
const abbr = route.params.abbr as string
/** The URL of that instance */
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

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

View File

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

View File

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

View File

@@ -2,5 +2,14 @@ module.exports = {
transpileDependencies: [
'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">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
@@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="NodaTime" Version="3.0.5" />
</ItemGroup>

View File

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

View File

@@ -2,6 +2,7 @@
module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types
open Microsoft.Extensions.Options
open NodaTime
// 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
type SkillForm = {
/// The ID of this skill

View File

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

View File

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

View File

@@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth
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>]
type MastodonAccount () =
/// The user name (what we store as naUser)
/// The user name (what we store as mastodonUser)
[<JsonPropertyName "username">]
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">]
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">]
member val DisplayName = "" with get, set
/// The user's profile URL
@@ -20,26 +20,32 @@ type MastodonAccount () =
member val Url = "" with get, set
open FSharp.Control.Tasks
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open System
open System.Net.Http
open System.Net.Http.Headers
open System.Net.Http.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
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 =
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
{| client_id = cfg.["ClientId"]
client_secret = cfg.["Secret"]
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
http.PostAsJsonAsync($"{inst.Url}/oauth/token",
{| client_id = inst.ClientId
client_secret = inst.Secret
redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
grant_type = "authorization_code"
code = authCode
scope = "read"
@@ -49,11 +55,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
match tokenResponse with
| null ->
return Error "Could not parse authorization code result"
| null -> return Error "Could not parse authorization code result"
| _ ->
// 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
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
use! profileResult = http.SendAsync req
@@ -62,19 +67,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
| true ->
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
| null ->
return Error "Could not parse profile result"
| x when x.Username <> x.AccountName ->
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})"
| null -> return Error "Could not parse profile result"
| profile -> return Ok profile
| false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
| false ->
let! err = codeResult.Content.ReadAsStringAsync ()
log.LogError $"Could not get token result from Mastodon:\n {err}"
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
}
@@ -86,7 +85,7 @@ open System.Security.Claims
open System.Text
/// 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 token =
@@ -100,8 +99,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com",
SigningCredentials = SigningCredentials (
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
SecurityAlgorithms.HmacSha256Signature)
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
)
)
tokenHandler.WriteToken token

View File

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

View File

@@ -1,7 +1,6 @@
/// Route handlers for Giraffe endpoints
module JobsJobsJobs.Api.Handlers
open FSharp.Control.Tasks
open Giraffe
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes
@@ -23,7 +22,7 @@ module Error =
/// URL prefixes for the Vue app
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"
]
@@ -31,15 +30,15 @@ module Error =
let notFound : HttpHandler =
fun next ctx -> task {
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
| 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"
return! Vue.app next ctx
| _ ->
log.LogInformation "Returning 404"
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
ctx
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
}
/// Handler that returns a 403 NOT AUTHORIZED response
@@ -58,6 +57,7 @@ module Helpers =
open NodaTime
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Options
open RethinkDb.Driver.Net
open System.Security.Claims
@@ -67,6 +67,9 @@ module Helpers =
/// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the authorization configuration from the request context
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
/// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
@@ -104,23 +107,27 @@ module Helpers =
module Citizen =
// GET: /api/citizen/log-on/[code]
let logOn authCode : HttpHandler =
let logOn (abbr, authCode) : HttpHandler =
fun next ctx -> task {
// 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)
match! Auth.verifyWithMastodon authCode cfg log with
match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with
| Ok account ->
// Step 2 - Find / establish Jobs, Jobs, Jobs account
let now = (clock ctx).GetCurrentInstant ()
let dbConn = conn ctx
let! citizen = task {
match! Data.Citizen.findByNaUser account.Username dbConn with
match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with
| None ->
let it : Citizen =
{ id = CitizenId.create ()
naUser = account.Username
instance = instance.Abbr
mastodonUser = account.Username
displayName = noneIfEmpty account.DisplayName
realName = None
profileUrl = account.Url
@@ -142,8 +149,8 @@ module Citizen =
citizenId = CitizenId.toString citizen.id
name = Citizen.name citizen
} next ctx
| Error err ->
return! RequestErrors.BAD_REQUEST err next ctx
| Error err -> return! RequestErrors.BAD_REQUEST err next ctx
| None -> return! Error.notFound next ctx
}
// 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
[<RequireQualifiedAccess>]
module Listing =
@@ -261,7 +287,7 @@ module Listing =
// PATCH: /api/listing/[id]
let expire listingId : HttpHandler =
authorize
>=> fun next ctx -> task {
>=> fun next ctx -> FSharp.Control.Tasks.Affine.task {
let dbConn = conn ctx
let now = clock(ctx).GetCurrentInstant ()
match! Data.Listing.findById (ListingId listingId) dbConn with
@@ -282,7 +308,6 @@ module Listing =
| None -> ()
return! ok next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/listing/search
@@ -489,12 +514,13 @@ let allEndpoints = [
subRoute "/api" [
subRoute "/citizen" [
GET_HEAD [
routef "/log-on/%s" Citizen.logOn
routef "/log-on/%s/%s" Citizen.logOn
routef "/%O" Citizen.get
]
DELETE [ route "" Citizen.delete ]
]
GET_HEAD [ route "/continents" Continent.all ]
GET_HEAD [ route "/instances" Instances.all ]
subRoute "/listing" [
GET_HEAD [
routef "/%O" Listing.get

View File

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