Initial commit

TODO: change api.conf URL references to use environment variables and add these variables to the docker-compose configuration for host domain
This commit is contained in:
2023-03-28 00:08:50 -07:00
parent 2d6d44b89f
commit 9f2473801c
82 changed files with 13974 additions and 1 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# JWT_SECRET=thisisastring # use if you change from simple bearer to JWT token auth
BEARER_TOKEN=thisisastring
API_PORT=9230
DB_HOST=db
DB_PORT=3306
# DB_PORT=3306
DB_ROOT_PASSWORD=thisisanotherstring
DB_DATABASE=17thCoreData
DB_USER=apiuser
DB_PASSWORD=thisisathirdstring

135
.gitignore vendored Normal file
View File

@@ -0,0 +1,135 @@
/mysql/**
!/mysql/.gitkeep
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

14
17th-web/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

28
17th-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

3
17th-web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

35
17th-web/README.md Normal file
View File

@@ -0,0 +1,35 @@
# 17th-web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

20
17th-web/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="./favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<!-- <link rel="stylesheet" href="./src/style.css"> -->
<title>17th Info Site</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3522
17th-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
17th-web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "17th-web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@headlessui/vue": "^1.7.12",
"@heroicons/vue": "^2.0.16",
"axios": "^1.3.4",
"body-parser": "^1.20.2",
"mysql2": "^3.2.0",
"pinia": "^2.0.32",
"sequelize": "^6.29.3",
"vue": "^3.2.47",
"vue-router": "^4.1.6",
"vuetify": "^3.1.10"
},
"devDependencies": {
"@mdi/font": "^7.2.96",
"@rushstack/eslint-patch": "^1.2.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-prettier": "^7.1.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.34.0",
"eslint-plugin-vue": "^9.9.0",
"postcss": "^8.4.21",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"vite": "^4.1.4"
}
}

View File

@@ -0,0 +1,10 @@
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
module.exports = {
plugins: {
tailwindcss,
autoprefixer,
},
}

BIN
17th-web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

95
17th-web/src/App.vue Normal file
View File

@@ -0,0 +1,95 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<v-app full-height>
<v-card class="m-auto" color="grey-lighten-3">
<v-layout>
<v-app-bar color="teal-darken-4" image="https://picsum.photos/1920/1080?random" prominent>
<template v-slot:image>
<v-img gradient="to top right, rgba(19,84,122,.8), rgba(128,208,199,.8)"></v-img>
</template>
<template v-slot:prepend>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
</template>
<v-app-bar-title>17th Ranger Battalion</v-app-bar-title>
<v-spacer></v-spacer>
<router-link to="/courses" custom v-slot="{ navigate }">
<v-btn icon="mdi-school-outline" @click="navigate"></v-btn>
</router-link>
<router-link to="/ribbons" custom v-slot="{ navigate }">
<v-btn icon="mdi-seal-variant" @click="navigate"></v-btn>
</router-link>
<v-btn icon="mdi-dots-vertical"> </v-btn>
</v-app-bar>
<v-navigation-drawer>
<v-list>
<v-list-item
prepend-avatar="https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png"
title="17th Admin"
subtitle="testuser@example.com"
></v-list-item>
</v-list>
<v-divider></v-divider>
<v-list>
<v-list-item
v-for="item in items"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
:to="item.href"
>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container fluid>
<RouterView />
</v-container>
</v-main>
</v-layout>
</v-card>
</v-app>
</template>
<script>
export default {
name: 'App',
components: {
RouterView
},
data() {
return {
drawer: true,
items: [
{ title: 'Home', href: '/', icon: 'mdi-home' },
{ title: 'Ribbons', href: '/ribbons', icon: 'mdi-seal-variant' },
{ title: 'Courses', href: '/courses', icon: 'mdi-school-outline' },
{ title: 'About', href: '/about', icon: 'mdi-information-outline' }
],
rail: true
}
},
watch: {}
}
</script>
<style>
/* tailwindcss */
/* @import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities'; */
</style>

View File

@@ -0,0 +1,63 @@
export function bufferToBase64 (buf) {
var binstr = Array.prototype.map.call(buf, function (ch) {
return String.fromCharCode(ch);
}).join('');
return window.btoa(binstr);
}
export function base64ToBuffer (base64) {
var binstr = window.atob(base64);
var buf = new Uint8Array(binstr.length);
Array.prototype.forEach.call(binstr, function (ch, i) {
buf[i] = ch.charCodeAt(0);
});
return buf;
}
export function dataURLToBlob (dataURL) {
var BASE64_MARKER = ';base64,';
if (dataURL.indexOf(BASE64_MARKER) == -1) {
var parts = dataURL.split(',');
var contentType = parts[0].split(':')[1];
var raw = parts[1];
return new Blob([raw], { type: contentType });
}
var parts = dataURL.split(BASE64_MARKER);
var contentType = parts[0].split(':')[1];
var raw = window.atob(parts[1]);
var rawLength = raw.length;
var uInt8Array = new Uint8Array(rawLength);
for (var i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
}
export function dataURLToBase64 (dataURL) {
var BASE64_MARKER = ';base64,';
if (dataURL.indexOf(BASE64_MARKER) == -1) {
var parts = dataURL.split(',');
var contentType = parts[0].split(':')[1];
var raw = parts[1];
return raw;
}
var parts = dataURL.split(BASE64_MARKER);
var contentType = parts[0].split(':')[1];
var raw = window.atob(parts[1]);
var rawLength = raw.length;
var uInt8Array = new Uint8Array(rawLength);
for (var i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return uInt8Array;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,52 @@
<template>
<div class="panel panel-primary">
<div class="panel-heading">
<h2>Courses</h2>
</div>
<div id="courses" class="panel-body">
<div class="list-group">
<div class="list-group-item" v-for="course in courses" :key="course">
<h4 class="list-group-item-heading">{{ course.name }}</h4>
<div
class="list-group-item-text"
style="display: flex; flex-direction: row; justify-content: space-between"
>
<div>
<p>{{ course.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
mounted() {
// Get the courses from the server
fetch('http://localhost:3001/api/courses')
.then((response) => response.json())
.then((data) => {
this.courses = data
})
},
data() {
return {
courses: [
{
name: 'Course 1',
description: 'This is a course'
},
{
name: 'Course 2',
description: 'This is a course'
}
]
}
}
}
</script>
<style>
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
defineProps({
msg: {
type: String,
required: true
}
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<v-app-bar color="teal-darken-4" image="https://picsum.photos/1920/1080?random" prominent>
<template v-slot:image>
<v-img gradient="to top right, rgba(19,84,122,.8), rgba(128,208,199,.8)"></v-img>
</template>
<template v-slot:prepend>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
</template>
<v-app-bar-title>Title</v-app-bar-title>
<v-spacer></v-spacer>
<router-link to="/courses" custom v-slot="{ navigate }">
<v-btn icon="mdi-school-outline" @click="navigate"></v-btn>
</router-link>
<router-link to="/ribbons" custom v-slot="{ navigate }">
<v-btn icon="mdi-seal-variant" @click="navigate"></v-btn>
</router-link>
<v-btn icon="mdi-dots-vertical"> </v-btn>
</v-app-bar>
</template>
<script>
export default {
name: 'NavBar',
data() {
return {
navigation: [
{ name: 'Home', href: '/', current: this.$route.path.includes('home') },
{ name: 'Ribbons', href: '/ribbons', current: this.$route.path.includes('ribbons') },
{ name: 'Courses', href: '/courses', current: this.$route.path.includes('courses') }
],
drawer: true,
items: [
{ title: 'Home', icon: 'mdi-home-city' },
{ title: 'My Account', icon: 'mdi-account' },
{ title: 'Users', icon: 'mdi-account-group-outline' }
],
rail: true
}
},
watch: {
$route() {
this.navigation = [
{ name: 'Home', href: '/', current: this.$route.path.includes('home') },
{ name: 'Ribbons', href: '/ribbons', current: this.$route.path.includes('ribbons') },
{ name: 'Courses', href: '/courses', current: this.$route.path.includes('courses') }
]
}
}
}
</script>
<style scoped>
#navbar {
background-color: #3f51b5;
color: white;
position: fixed;
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,85 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

8
17th-web/src/config.js Normal file
View File

@@ -0,0 +1,8 @@
import axios from "axios";
export const httpConfig = axios.create({
baseURL: "http://localhost:3001/api",
headers: {
"Content-type": "application/json"
}
})

17
17th-web/src/main.js Normal file
View File

@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import router from '@/router'
import '@/style.css';
// Vuetify
import vuetify from '@/plugins/vuetify'
const app = createApp(App)
app.use(vuetify)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,16 @@
import '@mdi/font/css/materialdesignicons.css' // Ensure you are using css-loader
import { createVuetify } from 'vuetify'
import 'vuetify/styles'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { md2 } from 'vuetify/blueprints'
export default createVuetify({
blueprint: md2,
components,
directives,
icons: {
defaultSet: 'mdi', // This is already the default value - only for display purposes
},
})

View File

@@ -0,0 +1,17 @@
export default [
{
path: '/courses',
name: 'courses',
component: () => import('@/views/dbViews/CoursesView.vue')
},
{
path: '/courses/:id',
name: 'course',
component: () => import('@/views/dbViews/CourseView.vue')
},
{
path: '/courses/add',
name: 'add-course',
component: () => import('@/views/dbViews/AddCourseView.vue')
}
]

View File

@@ -0,0 +1,17 @@
export default [
{
path: '/qualification-categories',
name: 'qualification-categories',
component: () => import('@/views/dbViews/QualificationCategoriesView.vue')
},
{
path: '/qualification-categories/:id',
name: 'qualification-category',
component: () => import('@/views/dbViews/QualificationCategoryView.vue')
},
{
path: '/qualification-categories/add',
name: 'add-qualification-category',
component: () => import('@/views/dbViews/AddQualificationCategoryView.vue')
}
]

View File

@@ -0,0 +1,17 @@
export default [
{
path: '/ribbons',
name: 'ribbons',
component: () => import('@/views/dbViews/RibbonsView.vue')
},
{
path: '/ribbons/:id',
name: 'ribbon',
component: () => import('@/views/dbViews/RibbonView.vue')
},
{
path: '/ribbons/add',
name: 'add-ribbon',
component: () => import('@/views/dbViews/AddRibbonView.vue')
}
]

View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import RibbonRoutes from './dbRoutes/Ribbon.js'
import CourseRoutes from './dbRoutes/Course.js'
import QualificationCategoryRoutes from './dbRoutes/QualificationCategory.js'
const routes =
[
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
...RibbonRoutes,
...CourseRoutes,
...QualificationCategoryRoutes
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router

View File

@@ -0,0 +1,33 @@
import { httpConfig } from "@/config.js";
class CourseDataService {
getAll () {
return httpConfig.get("/courses");
}
get (id) {
return httpConfig.get(`/courses/${id}`);
}
create (data) {
return httpConfig.post("/courses", data);
}
update (id, data) {
return httpConfig.put(`/courses/${id}`, data);
}
delete (id) {
return httpConfig.delete(`/courses/${id}`);
}
deleteAll () {
return httpConfig.delete(`/courses`);
}
findByName (name) {
return httpConfig.get(`/courses?name=${name}`);
}
}
export default new CourseDataService();

View File

@@ -0,0 +1,33 @@
import { httpConfig } from "@/config.js";
class QualificationCategoryDataService {
getAll () {
return httpConfig.get("/qualification-categories");
}
get (id) {
return httpConfig.get(`/qualification-categories/${id}`);
}
create (data) {
return httpConfig.post("/qualification-categories", data);
}
update (id, data) {
return httpConfig.put(`/qualification-categories/${id}`, data);
}
delete (id) {
return httpConfig.delete(`/qualification-categories/${id}`);
}
deleteAll () {
return httpConfig.delete(`/qualification-categories`);
}
findByName (name) {
return httpConfig.get(`/qualification-categories?name=${name}`);
}
}
export default new QualificationCategoryDataService();

View File

@@ -0,0 +1,39 @@
import { httpConfig } from "@/config.js";
import axios from "axios";
class RibbonDataService {
getAll () {
return httpConfig.get("/ribbons");
}
get (id) {
return httpConfig.get(`/ribbons/${id}`);
}
create (data) {
return httpConfig.post("/ribbons", data);
// return axios.post("https://httpbin.org/post", data)
}
update (id, data) {
return httpConfig.put(`/ribbons/${id}`, data);
}
delete (id) {
return httpConfig.delete(`/ribbons/${id}`);
}
deleteAll () {
return httpConfig.delete(`/ribbons`);
}
findByName (name) {
return httpConfig.get(`/ribbons?name=${name}`);
}
getCategories () {
return httpConfig.get("/qualification-categories");
}
}
export default new RibbonDataService();

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

7
17th-web/src/style.css Normal file
View File

@@ -0,0 +1,7 @@
#app {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}

View File

@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,9 @@
<script setup>
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -0,0 +1,121 @@
<template>
<div class="submit-form">
<div v-if="!submitted">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
class="form-control"
id="name"
required
v-model="course.name"
name="name"
/>
</div>
<div class="form-group">
<label for="shortname">Short Name</label>
<input
type="text"
class="form-control"
id="shortname"
required
v-model="course.shortname"
name="shortname"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<input
class="form-control"
id="description"
required
v-model="course.description"
name="description"
/>
</div>
<!-- allow image upload for blob -->
<div class="form-group">
<label for="image">Image</label>
<input
type="file"
class="form-control"
id="image"
required
name="image"
@change="onImageChange"
/>
</div>
<button @click="saveCourse" class="btn btn-success">Submit</button>
</div>
<div v-else>
<h4>You submitted successfully!</h4>
<button class="btn btn-success" @click="newCourse">Add</button>
</div>
</div>
</template>
<script>
import CourseDataService from '@/services/CourseDataService'
export default {
name: 'add-course',
data() {
return {
course: {
id: null,
name: '',
shortname: '',
description: '',
image: new Blob(),
category: '',
footprint: ''
},
submitted: false
}
},
methods: {
saveCourse() {
var data = {
name: this.course.name,
shortname: this.course.shortname,
description: this.course.description,
image: this.course.image ? this.course.image : null,
category: this.course.category,
footprint: this.course.footprint
}
CourseDataService.create(data)
.then((response) => {
this.course.id = response.data.id
console.log(response.data)
this.submitted = true
})
.catch((e) => {
console.log(e)
})
},
newCourse() {
this.submitted = false
this.course = {}
},
onImageChange(e) {
const file = e.target.files[0]
this.course.image = new Blob([file])
}
}
}
</script>
<style>
.submit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="submit-form">
<div v-if="!submitted">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
class="form-control"
id="name"
required
v-model="qualificationCategory.name"
name="name"
/>
</div>
<button @click="saveQualificationCategory" class="btn btn-success">Submit</button>
</div>
<div v-else>
<h4>You submitted successfully!</h4>
<button class="btn btn-success" @click="newQualificationCategory">Add</button>
</div>
</div>
</template>
<script>
import QualificationCategoryDataService from '@/services/QualificationCategoryDataService'
export default {
name: 'add-qualification-category',
data() {
return {
qualificationCategory: {
id: null,
name: ''
},
submitted: false
}
},
methods: {
saveQualificationCategory() {
var data = {
name: this.qualificationCategory.name
}
QualificationCategoryDataService.create(data)
.then((response) => {
this.qualificationCategory.id = response.data.id
console.log(response.data)
this.submitted = true
})
.catch((e) => {
console.log(e)
})
},
newQualificationCategory() {
this.submitted = false
this.qualificationCategory = {}
}
}
}
</script>
<style>
.submit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="submit-form">
<div v-if="!submitted">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
class="form-control"
id="name"
required
v-model="ribbon.name"
name="name"
/>
</div>
<div class="form-group">
<label for="shortname">Short Name</label>
<input
type="text"
class="form-control"
id="shortname"
required
v-model="ribbon.shortname"
name="shortname"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<input
class="form-control"
id="description"
required
v-model="ribbon.description"
name="description"
/>
</div>
<!-- allow image upload for blob -->
<div class="form-group">
<label for="image">Image</label>
<input
type="file"
class="form-control"
id="image"
required
name="image"
@change="onImageChange"
/>
</div>
<!-- footprint -->
<!-- front-end validation only -->
<!-- select between badge and ribbon type -->
<div class="form-group">
<label for="footprint">Footprint</label>
<select
class="form-control"
id="footprint"
required
v-model="ribbon.footprint"
name="footprint"
>
<option value="badge">Badge</option>
<option value="ribbon">Ribbon</option>
</select>
</div>
<!-- select list of categories -->
<div class="form-group">
<label for="category">Category</label>
<select
class="form-control"
id="category"
required
v-model="ribbon.category"
name="category"
@click="getCategories"
>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<button @click="saveRibbon" class="btn btn-success">Submit</button>
</div>
<div v-else>
<h4>You submitted successfully!</h4>
<button class="btn btn-success" @click="newRibbon">Add</button>
</div>
</div>
</template>
<script>
import RibbonDataService from '@/services/RibbonDataService'
export default {
name: 'add-ribbon',
data() {
return {
ribbon: {
id: null,
name: '',
shortname: '',
description: '',
image: null,
category: '',
footprint: ''
},
categories: [],
submitted: false
}
},
mounted() {
this.getCategories()
},
methods: {
saveRibbon() {
var data = {
name: this.ribbon.name,
shortname: this.ribbon.shortname,
description: this.ribbon.description,
image: this.ribbon.image ? this.ribbon.image : null,
category: this.ribbon.category,
footprint: this.ribbon.footprint
}
RibbonDataService.create(data)
.then((response) => {
this.ribbon.id = response.data.id
console.log(response.data)
this.submitted = true
})
.catch((e) => {
console.log(e)
})
},
newRibbon() {
this.submitted = false
this.ribbon = {}
},
getCategories() {
RibbonDataService.getCategories()
.then((response) => {
this.categories = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
onImageChange(e) {
const file = e.target.files[0]
this.ribbon.image = new Blob([file])
}
}
}
</script>
<style>
.submit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div v-if="currentCourse" class="edit-form">
<h4>Course</h4>
<form>
<div class="form-group">
<label for="name">Name</label>
<input type="text" class="form-control" id="name" v-model="currentCourse.name" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input
type="text"
class="form-control"
id="description"
v-model="currentCourse.description"
/>
</div>
<div class="form-group">
<label><strong>Status:</strong></label>
{{ currentCourse.published ? 'Published' : 'Pending' }}
</div>
</form>
<button
class="badge badge-primary mr-2"
v-if="currentCourse.published"
@click="updatePublished(false)"
>
UnPublish
</button>
<button v-else class="badge badge-primary mr-2" @click="updatePublished(true)">Publish</button>
<button class="badge badge-danger mr-2" @click="deleteCourse">Delete</button>
<button type="submit" class="badge badge-success" @click="updateCourse">Update</button>
<p>{{ message }}</p>
</div>
<div v-else>
<br />
<p>Please click on a Course...</p>
</div>
</template>
<script>
import CourseDataService from '@/services/CourseDataService'
export default {
name: 'course',
data() {
return {
currentCourse: null,
message: ''
}
},
methods: {
getCourse(id) {
CourseDataService.get(id)
.then((response) => {
this.currentCourse = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
updatePublished(status) {
var data = {
id: this.currentCourse.id,
name: this.currentCourse.name,
description: this.currentCourse.description,
published: status
}
CourseDataService.update(this.currentCourse.id, data)
.then((response) => {
console.log(response.data)
this.currentCourse.published = status
this.message = 'The status was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
updateCourse() {
CourseDataService.update(this.currentCourse.id, this.currentCourse)
.then((response) => {
console.log(response.data)
this.message = 'The course was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
deleteCourse() {
CourseDataService.delete(this.currentCourse.id)
.then((response) => {
console.log(response.data)
this.$router.push({ name: 'courses' })
})
.catch((e) => {
console.log(e)
})
}
},
mounted() {
this.message = ''
this.getCourse(this.$route.params.id)
}
}
</script>
<style>
.edit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="list row">
<div class="col-md-8">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Search by name" v-model="name" />
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" @click="searchName">
Search
</button>
</div>
</div>
</div>
<div class="col-md-6">
<h4>Courses List</h4>
<ul class="list-group">
<li
class="list-group-item"
:class="{ active: index == currentIndex }"
v-for="(course, index) in courses"
:key="index"
@click="setActiveCourse(course, index)"
>
{{ course.name }}
</li>
</ul>
<button class="m-3 btn btn-sm btn-danger" @click="removeAllCourses">Remove All</button>
</div>
<div class="col-md-6">
<div v-if="currentCourse">
<h4>Course</h4>
<div>
<label><strong>Name:</strong></label> {{ currentCourse.name }}
</div>
<div>
<label><strong>Description:</strong></label> {{ currentCourse.description }}
</div>
<div>
<label><strong>Status:</strong></label>
{{ currentCourse.published ? 'Published' : 'Pending' }}
</div>
<router-link :to="'/courses/' + currentCourse.id" class="badge badge-warning"
>Edit</router-link
>
</div>
<div v-else>
<br />
<p>Please click on a Course...</p>
</div>
</div>
</div>
</template>
<script>
import CourseDataService from '@/services/CourseDataService'
export default {
name: 'courses-list',
data() {
return {
courses: [],
currentCourse: null,
currentIndex: -1,
name: ''
}
},
methods: {
retrieveCourses() {
CourseDataService.getAll()
.then((response) => {
this.courses = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
refreshList() {
this.retrieveCourses()
this.currentCourse = null
this.currentIndex = -1
},
setActiveCourse(course, index) {
this.currentCourse = course
this.currentIndex = course ? index : -1
},
removeAllCourses() {
CourseDataService.deleteAll()
.then((response) => {
console.log(response.data)
this.refreshList()
})
.catch((e) => {
console.log(e)
})
},
searchName() {
CourseDataService.findByName(this.name)
.then((response) => {
this.courses = response.data
this.setActiveCourse(null)
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
}
},
mounted() {
this.retrieveCourses()
}
}
</script>
<style>
.list {
text-align: left;
max-width: 750px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="list row">
<div class="col-md-8">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Search by name" v-model="name" />
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" @click="searchName">
Search
</button>
</div>
</div>
</div>
<div class="col-md-6">
<h4>Courses List</h4>
<ul class="list-group">
<li
class="list-group-item"
:class="{ active: index == currentIndex }"
v-for="(course, index) in courses"
:key="index"
@click="setActiveCourse(course, index)"
>
{{ course.name }}
</li>
</ul>
<button class="m-3 btn btn-sm btn-danger" @click="removeAllCourses">Remove All</button>
</div>
<div class="col-md-6">
<div v-if="currentCourse">
<h4>Course</h4>
<div>
<label><strong>Name:</strong></label> {{ currentCourse.name }}
</div>
<div>
<label><strong>Description:</strong></label> {{ currentCourse.description }}
</div>
<div>
<label><strong>Status:</strong></label>
{{ currentCourse.published ? 'Published' : 'Pending' }}
</div>
<router-link :to="'/courses/' + currentCourse.id" class="badge badge-warning"
>Edit</router-link
>
</div>
<div v-else>
<br />
<p>Please click on a Course...</p>
</div>
</div>
</div>
</template>
<script>
import CourseDataService from '@/services/CourseDataService'
export default {
name: 'courses-list',
data() {
return {
courses: [],
currentCourse: null,
currentIndex: -1,
name: ''
}
},
methods: {
retrieveCourses() {
CourseDataService.getAll()
.then((response) => {
this.courses = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
refreshList() {
this.retrieveCourses()
this.currentCourse = null
this.currentIndex = -1
},
setActiveCourse(course, index) {
this.currentCourse = course
this.currentIndex = course ? index : -1
},
removeAllCourses() {
CourseDataService.deleteAll()
.then((response) => {
console.log(response.data)
this.refreshList()
})
.catch((e) => {
console.log(e)
})
},
searchName() {
CourseDataService.findByName(this.name)
.then((response) => {
this.courses = response.data
this.setActiveCourse(null)
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
}
},
mounted() {
this.retrieveCourses()
}
}
</script>
<style>
.list {
text-align: left;
max-width: 750px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div v-if="currentCourse" class="edit-form">
<h4>Course</h4>
<form>
<div class="form-group">
<label for="name">Name</label>
<input type="text" class="form-control" id="name" v-model="currentCourse.name" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input
type="text"
class="form-control"
id="description"
v-model="currentCourse.description"
/>
</div>
<div class="form-group">
<label><strong>Status:</strong></label>
{{ currentCourse.published ? 'Published' : 'Pending' }}
</div>
</form>
<button
class="badge badge-primary mr-2"
v-if="currentCourse.published"
@click="updatePublished(false)"
>
UnPublish
</button>
<button v-else class="badge badge-primary mr-2" @click="updatePublished(true)">Publish</button>
<button class="badge badge-danger mr-2" @click="deleteCourse">Delete</button>
<button type="submit" class="badge badge-success" @click="updateCourse">Update</button>
<p>{{ message }}</p>
</div>
<div v-else>
<br />
<p>Please click on a Course...</p>
</div>
</template>
<script>
import CourseDataService from '@/services/CourseDataService'
export default {
name: 'course',
data() {
return {
currentCourse: null,
message: ''
}
},
methods: {
getCourse(id) {
CourseDataService.get(id)
.then((response) => {
this.currentCourse = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
updatePublished(status) {
var data = {
id: this.currentCourse.id,
name: this.currentCourse.name,
description: this.currentCourse.description,
published: status
}
CourseDataService.update(this.currentCourse.id, data)
.then((response) => {
console.log(response.data)
this.currentCourse.published = status
this.message = 'The status was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
updateCourse() {
CourseDataService.update(this.currentCourse.id, this.currentCourse)
.then((response) => {
console.log(response.data)
this.message = 'The course was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
deleteCourse() {
CourseDataService.delete(this.currentCourse.id)
.then((response) => {
console.log(response.data)
this.$router.push({ name: 'courses' })
})
.catch((e) => {
console.log(e)
})
}
},
mounted() {
this.message = ''
this.getCourse(this.$route.params.id)
}
}
</script>
<style>
.edit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="p-10">
<v-form class="mt-5 md:col-span-2 md:mt-0" v-if="currentRibbon">
<v-container>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="currentRibbon.name" label="Ribbon Name" outlined></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model="currentRibbon.shortname"
label="Short Name"
outlined
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox
label="Category"
v-model="selectedCategory"
:items="categories.map((category) => category.name)"
item-text="name"
>
</v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox
v-model="currentRibbon.footprint"
:items="['ribbon', 'badge']"
label="Footprint"
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-textarea
v-model="currentRibbon.description"
label="Description"
counter="500"
variant="outlined"
></v-textarea>
</v-row>
<v-row>
<v-img
v-if="displayedImage"
:src="displayedImage"
max-width="200"
max-height="200"
></v-img>
</v-row>
<v-row>
<v-file-input
label="Image"
prepend-icon="mdi-camera"
accept="image/*"
@change="onFileChange"
outlined
></v-file-input>
</v-row>
<v-row>
<v-btn-group variant="flat">
<v-btn color="error" @click="deleteRibbon">Delete</v-btn>
<v-btn color="info" @click="revertRibbon">Reset</v-btn>
<v-btn color="success" @click="updateRibbon">Save</v-btn>
</v-btn-group>
</v-row>
</v-container>
</v-form>
</div>
</template>
<script>
import RibbonDataService from '@/services/RibbonDataService'
import { bufferToBase64 } from '@/assets/js/imageUtils.js'
export default {
name: 'ribbon',
data() {
return {
currentRibbon: null,
message: '',
categories: [],
selectedCategory: '',
displayedImage: null
}
},
methods: {
getRibbon(id) {
RibbonDataService.get(id)
.then((response) => {
this.currentRibbon = response.data
this.selectedCategory = this.currentRibbon.category?.name
if (this.currentRibbon.image) {
console.log(this.currentRibbon.image)
bufferToBase64(this.currentRibbon.image.data, (base64) => {
this.displayedImage = 'data:image/png;base64,' + base64
this.currentRibbon.image = base64
})
}
console.log(this.currentRibbon)
})
.catch((e) => {
console.log(e)
})
},
updatePublished(status) {
var data = {
id: this.currentRibbon.id,
name: this.currentRibbon.name,
description: this.currentRibbon.description,
published: status
}
RibbonDataService.update(this.currentRibbon.id, data)
.then((response) => {
console.log(response.data)
this.currentRibbon.published = status
this.message = 'The status was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
updateRibbon() {
RibbonDataService.update(this.currentRibbon.id, this.currentRibbon)
.then((response) => {
console.log(response.data)
this.message = 'The ribbon was updated successfully!'
})
.catch((e) => {
console.log(e)
})
},
deleteRibbon() {
RibbonDataService.delete(this.currentRibbon.id)
.then((response) => {
console.log(response.data)
this.$router.push({ name: 'ribbons' })
})
.catch((e) => {
console.log(e)
})
},
revertRibbon() {
this.currentRibbon = null
this.displayedImage = null
this.getRibbon(this.$route.params.id)
},
getCategories() {
RibbonDataService.getCategories()
.then((response) => {
this.categories = response.data
console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
onFileChange(e) {
const file = e.target.files[0]
// convert file to dataUrl
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
this.displayedImage = reader.result
// convert data to Blob then buffer
const dataUrl = reader.result
const base64 = dataUrl.split(',')[1]
const buffer = new Uint8Array(
atob(base64)
.split('')
.map((char) => char.charCodeAt(0))
)
this.currentRibbon.image = buffer
}
}
},
mounted() {
this.message = ''
this.getCategories()
this.getRibbon(this.$route.params.id)
}
}
</script>
<style>
.edit-form {
max-width: 300px;
margin: auto;
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<v-container>
<v-card>
<v-toolbar color="primary">
<v-toolbar-title> Search and Filter </v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-row class="px-3 justify-center" align="center">
<v-col cols="12" sm="6" md="4">
<v-form>
<v-row class="px-3">
<v-text-field v-model="searchInputName" label="Name" outlined />
<v-btn @click="searchByName" icon="mdi-magnify"></v-btn>
</v-row>
</v-form>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox
v-model="searchSelectionsCategories"
:items="searchCategories"
label="Categories"
multiple
chips
></v-combobox>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-combobox
v-model="searchSelectionsFootprints"
:items="searchFootprints"
label="Footprint"
multiple
chips
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-btn to="/ribbons/add" color="green-darken-4"> Add Ribbon </v-btn>
</v-row>
</v-card-text>
</v-card>
<v-card
v-for="(ribbon, index) in ribbonsFiltered"
:key="index"
class="mt-5 mb-8"
color="primary"
>
<v-row justify="space-between">
<v-col cols="auto">
<v-card-title>
{{ ribbon.name }}
{{ ribbon.footprint.charAt(0).toUpperCase() + ribbon.footprint.slice(1) }}
</v-card-title>
<v-card-subtitle>
{{ ribbon.shortname }}
</v-card-subtitle>
<v-card-text>
<p>{{ ribbon.description }}</p>
</v-card-text>
<v-card-actions>
<v-chip class="ma-1" color="green" variant="flat" size="small">
<v-icon start icon="mdi-label"></v-icon>
{{ ribbon.footprint }}
</v-chip>
<v-chip v class="m-1" color="blue" variant="flat" size="small">
<v-icon start icon="mdi-label"></v-icon>
{{ ribbon.category?.name }}
</v-chip>
<v-btn :to="'/ribbons/' + ribbon.id" color="secondary" variant="flat" class="mx-2">
Edit Ribbon
</v-btn>
</v-card-actions>
</v-col>
<v-col cols="auto" class="m-10">
<div class="flex justify-center">
<v-img
v-if="ribbon.image"
:src="ribbon.image"
height="200"
width="200"
class="align-middle contain mx-12"
></v-img>
</div>
</v-col>
</v-row>
</v-card>
</v-container>
</template>
<script>
import RibbonDataService from '@/services/RibbonDataService.js'
import { bufferToBase64 } from '@/assets/js/imageUtils.js'
export default {
name: 'ribbons-list',
components: {},
data() {
return {
ribbons: [],
currentRibbon: null,
currentIndex: -1,
searchInputName: '',
searchCategories: [],
searchSelectionsCategories: [],
searchFootprints: [],
searchSelectionsFootprints: []
}
},
mounted() {
this.retrieveRibbons()
},
watch: {},
computed: {
ribbonsFiltered() {
// get unique values from our two query sets
if (
!this.searchInputName ||
!this.searchSelectionsCategories ||
!this.searchSelectionsFootprints
) {
return this.ribbons
}
return this.ribbons.filter((ribbon) => {
const nameMatches =
this.searchInputName === ''
? false
: ribbon.name.toLowerCase().includes(this.searchInputName.toLowerCase()) ||
ribbon.shortname.toLowerCase().includes(this.searchInputName.toLowerCase())
const categoryMatches = this.searchSelectionsCategories.includes(ribbon.category.name)
const footprintMatches = this.searchSelectionsFootprints.includes(ribbon.footprint)
return nameMatches && categoryMatches && footprintMatches
})
}
},
methods: {
retrieveRibbons() {
this.ribbonsFiltered
RibbonDataService.getAll()
.then((response) => {
console.log(response.data)
this.ribbons = response.data.map((ribbon) => {
return {
...ribbon,
image: ribbon.image ? this.getImageFromBuffer(ribbon.image) : null
}
})
this.ribbons = this.ribbons.sort((a, b) => (a.name > b.name ? 1 : -1))
this.searchCategories = [...new Set(this.ribbons.map((ribbon) => ribbon.category.name))]
this.searchSelectionsCategories = this.searchCategories
this.searchFootprints = [...new Set(this.ribbons.map((ribbon) => ribbon.footprint))]
this.searchSelectionsFootprints = this.searchFootprints
})
.catch((e) => {
console.log(e)
})
},
refreshList() {
this.retrieveRibbons()
this.currentRibbon = null
this.currentIndex = -1
},
setActiveRibbon(ribbon, index) {
this.currentRibbon = ribbon
this.currentIndex = ribbon ? index : -1
},
removeAllRibbons() {
RibbonDataService.deleteAll()
.then((response) => {
// console.log(response.data)
this.refreshList()
})
.catch((e) => {
console.log(e)
})
},
searchByName() {
RibbonDataService.findByName(this.searchInputName)
.then((response) => {
this.ribbons = response.data
this.setActiveRibbon(null)
// console.log(response.data)
})
.catch((e) => {
console.log(e)
})
},
getImageFromBuffer(blob) {
// Convert binary data to base64 encoded string
const data = new Uint8Array(blob.data)
console.log(data)
const blobData = new Blob([data], { type: 'image/png' })
console.log(blobData)
console.log(URL.createObjectURL(blobData))
return URL.createObjectURL(blobData)
}
}
}
</script>
<style>
.list {
text-align: left;
max-width: 750px;
margin: auto;
}
.list-group-item {
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
const colors = require('tailwindcss/colors')
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx,vue}",
],
plugins: [
],
theme: {
screens: {
sm: '480px',
md: '768px',
lg: '976px',
xl: '1440px',
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
serif: ['Merriweather', 'serif'],
},
extend: {
spacing: {
'128': '32rem',
'144': '36rem',
},
borderRadius: {
'4xl': '2rem',
}
}
}
}

16
17th-web/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
const path = require('path')
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
}
})

View File

@@ -1,2 +1,82 @@
# 17th-Battalion-Tracker
# 17th Website Project
## Basic Dev Environment
*Assumes you have Docker and Docker Compose installed (included in Docker Desktop).*
1. Copy `/.env.example` to `/.env` and populate with desired options.
1. Navigate to the project root in a terminal.
2. Run `docker compose up -d --build db`
3. Run `docker compose up -d --build api`
4. Let it build the images
You will have a SQL server accessible on port 12730 from your host (and publicly if not firewalled) for SQL Workbench access.
The API will launch and be accessible on port 3000 from your host via Postman or a browser.
### Nginx (optional)
*Adjust the listening ports*
Adjust the listening ports in two places:
`/nginx/api.conf`: Change the listening port in one or both servers
`/docker-compose.yaml`: Change the 'ports' entries in the nginx service. The first part is the host port to bind, the second part is the container port to bind. For example, `9000:3440` would route inbound requests for `localhost:9000` on to the nginx container (and the nginx service) at port 3440.
*Adding Nginx without SSL*
If you want to use Nginx without SSL, comment out the second `server` object in `nginx/api.conf` so you're only handling basic HTTP requests. Run `docker compose up -d --built nginx`.
*Adding Nginx with SSL*
You can use the commands in the other file of `/nginx` to generate self-signed certificates for the domain you're hosting this at. Certbot will remotely check routing for that domain to itself and assign a certificate if valid, based on the well known acme challenge already set up in the `/nginx/api.conf` file.
> IMPORTANT: If you want to do this, you'll need to comment out the second `server` object in `/nginx/api.conf` FIRST so Nginx doesn't crash when it looks for not-yet-existing SSL certs, and can respond on port 9230 (or whatever you've set it to) with the response. Then launch, ensure Nginx is running, then launch a Certbot command to check the domain endpoint.
## Services in Docker Compose
### DB
*MySQL database with relatively default configuration*
- references .env file for SQL root password and other information
- exposes SQL interface on port 12730 of the host - inbound access still subject to firewall restrictions
- natively accessible at the unchanged 3306 port for any containers within the same Compose project's default bridge network -- i.e., the api service in this project references the mysql container by hostname 'db', matching the service name
### API
*Node.js-served API using express.js and Sequelize. Documentation in [OpenAPI 3.0 spec](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md).*
- references DB connectivity details & shared secret for authenticating HTTP requests from .env file
- contains controllers, models, and routes
- controllers
- defines actions to be taken in response to incoming web requests
- includes data processing, database actions, and response actions
- models
- defines entity models - entity default fields and constraints
- routes
- pairs API endpoints with controller definitions
- junctionModels
- a folder for modeling entities that serve explicitly as junction points -- most notably 'event' formats
`db/index.js` imports models and defines relations among them. it exports a 'db' object containing the Sequelize instance as well as all imported models. This is referenced in the primary application, as a **database synchronization is performed at runtime**.
`/index.js` Contains the main application logic and express configuration. It currently uses CORS and a very in-development section, but with basic authentication through standard bearer tokens. a JWT alternative is commented out.
> This main logic also instructs Sequelize to sync the database on launch. **`force: true`, all data will be wiped** as tables are dropped to account for possible changes in the schema definitions. This is a development tool and should be disabled in production.
### Nginx and Certbot
Optional nginx instance for serving the API via reverse proxy on an external port of your choosing. This include a certbot container for acquiring a self-signed certificate, and bind mounts can be changed to utilize them to secure the API with SSL.
### 17th-web
*This is a Vue instance designed to interface with the API and display the information to users, as well as permit administrators to manipulate the database. This has fallen behind active API development and may no longer work. This can be replaced with AJ's demo site if favored.*
## Resources
- [Bootstrap](https://getbootstrap.com/)
- [Vue 3 CRUD Tutorial](https://www.bezkoder.com/vue-3-crud/)
- [Node.js & Sequelize (MySQL) Tutorial](https://www.bezkoder.com/node-js-express-sequelize-mysql/)

15
api/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# nodejs container
FROM node:latest
# Create app directory
WORKDIR /app
# Bundle app source
# COPY . /app
# Install app dependencies
# RUN npm install
EXPOSE $API_PORT
CMD [ "npm", "run", "start" ]

19
api/db/config.js Normal file
View File

@@ -0,0 +1,19 @@
// dotenv
// .config()
module.exports = {
HOST: process.env.DB_HOST,
PORT: process.env.DB_PORT,
// USER: process.env.DB_USER,
USER: 'root',
// PASSWORD: process.env.DB_PASSWORD,
PASSWORD: process.env.DB_ROOT_PASSWORD,
DATABASE: process.env.DB_DATABASE,
dialect: "mysql",
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
};

View File

@@ -0,0 +1,70 @@
const db = require("..");
const Award = db.Award;
const Op = db.Sequelize.Op;
// Create and Save a new Award
exports.create = (req, res) => {
// Validate
if (!req.body) {
res.status(400).send({
message: "Body content can not be empty!"
});
return;
}
const awards = []
if (Array.isArray(req.body)) {
awards.push(...req.body)
} else {
awards.push(req.body)
}
// Save
const promises = awards.map(award => {
return Award.create(award)
});
// Create
const successes = []
const failures = []
Promise.allSettled(promises)
.then(data => {
if (data.every(result => result.status === 'fulfilled')) {
res.status(201).send({
message: "All awards were created successfully.",
successes: data.map(result => result.value),
failures: [],
})
return;
}
data.forEach(result => {
if (result.status === 'fulfilled') {
successes.push(result.value)
} else {
failures.push(result.reason.errors)
}
})
if (successes.length === 0) {
res.status(500).send({
message:
"Failed to create any Awards.",
failures: failures,
successes: successes,
});
return;
}
res.status(207).send({
message: "Some Awards were created successfully.",
successes: successes,
failures: failures,
})
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while creating the Award.",
});
});
};

View File

@@ -0,0 +1,176 @@
const db = require("../");
const Course = db.Course;
const Op = db.Sequelize.Op;
// Create and Save a new Course
exports.create = (req, res) => {
// Validate
if (!req.body) {
res.status(400).send({
message: "Body content can not be empty!"
});
return;
}
// Create
const courses = []
if (Array.isArray(req.body)) {
courses.push(...req.body)
} else {
courses.push(req.body)
}
// Save
const promises = []
courses.forEach(course => {
promises.push(Course.create(course))
});
const successes = []
const failures = []
Promise.allSettled(promises)
.then(data => {
if (data.every(result => result.status === 'fulfilled')) {
res.status(201).send({
message: "All courses were created successfully.",
successes: data.map(result => result.value),
failures: [],
})
return;
}
data.forEach(result => {
if (result.status === 'fulfilled') {
successes.push(result.value)
} else {
failures.push(result.reason)
}
})
res.status(207).send({
message: "Some courses were created successfully.",
successes: successes,
failures: failures,
})
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while creating the Course.",
});
});
};
// Retrieve all Courses from the database.
exports.findAll = (req, res) => {
const name = req.query.name;
var condition = name ? { name: { [Op.like]: `%${name}%` } } : null;
Course.findAll({ where: condition, include: ['trainingsHeld'] })
.then(data => {
res.send(data);
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving courses."
});
});
};
// Find a single Course with an id
exports.findOne = (req, res) => {
const id = req.params.id;
Course.findByPk(id)
.then(data => {
res.send(data);
})
.catch(err => {
res.status(500).send({
message: "Error retrieving Course with id=" + id
});
});
};
// Update a Course by the id in the request
exports.update = (req, res) => {
const id = req.params.id;
Course.update(req.body, {
where: { id: id }
})
.then(num => {
if (num == 1) {
res.send({
message: "Course was updated successfully."
});
} else {
res.send({
message: `Cannot update Course with id=${id}. Maybe Course was not found or req.body is empty!`
});
}
})
.catch(err => {
res.status(500).send({
message: "Error updating Course with id=" + id
});
});
};
// Delete a Course with the specified id in the request
exports.delete = (req, res) => {
const id = req.params.id;
Course.destroy({
where: { id: id }
})
.then(num => {
if (num == 1) {
res.send({
message: "Course was deleted successfully!"
});
} else {
res.send({
message: `Cannot delete Course with id=${id}. Maybe Course was not found!`
});
}
})
.catch(err => {
res.status(500).send({
message: "Could not delete Course with id=" + id
});
});
};
// Delete all Courses from the database.
exports.deleteAll = (req, res) => {
Course.destroy({
where: {},
truncate: false
})
.then(nums => {
res.send({ message: `${nums} Courses were deleted successfully!` });
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while removing all courses."
});
});
};
// Find all published Courses
exports.findAllPublished = (req, res) => {
Course.findAll({ where: { published: true } })
.then(data => {
res.send(data);
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving courses."
});
});
};

View File

@@ -0,0 +1,100 @@
const db = require("..");
const Member = db.Member;
const Op = db.Sequelize.Op;
// Create and Save a new Member
exports.create = (req, res) => {
// Validate
if (!req.body) {
res.status(400).send({
message: "Body content can not be empty!"
});
return;
}
// Create
const members = []
if (Array.isArray(req.body)) {
members.push(...req.body)
} else {
members.push(req.body)
}
// Save
const promises = members.map(member => {
return Member.create(member)
.then(memberObj => {
if (req.body.rankId) {
db.Rank.findByPk(req.body.rankId)
.then(rank => {
memberObj.setRank(rank)
})
}
if (req.body.statusId) {
db.MemberStatus.findByPk(req.body.statusId)
.then(status => {
memberObj.setStatus(status)
})
}
return memberObj
})
});
// Create
const successes = []
const failures = []
Promise.allSettled(promises)
.then(data => {
if (data.every(result => result.status === 'fulfilled')) {
res.status(201).send({
message: "All members were created successfully.",
successes: data.map(result => result.value),
failures: [],
})
return;
}
data.forEach(result => {
if (result.status === 'fulfilled') {
successes.push(result.value)
} else {
failures.push(result.reason.errors)
}
})
if (successes.length === 0) {
res.status(500).send({
message:
"Failed to create any Members.",
failures: failures,
successes: successes,
});
return;
}
res.status(207).send({
message: "Some Members were created successfully.",
successes: successes,
failures: failures,
})
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while creating the Member.",
});
});
};
// Retrieve all Members from the database.
exports.findAll = (req, res) => {
Member.findAll()
.then(data => {
res.send(data);
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
});
});
};

View File

@@ -0,0 +1,70 @@
const db = require("..");
const Rank = db.Rank;
const Op = db.Sequelize.Op;
// Create and Save a new Rank
exports.create = (req, res) => {
// Validate
if (!req.body) {
res.status(400).send({
message: "Body content can not be empty!"
});
return;
}
const ranks = []
if (Array.isArray(req.body)) {
ranks.push(...req.body)
} else {
ranks.push(req.body)
}
// Save
const promises = ranks.map(rank => {
return Rank.create(rank)
});
// Create
const successes = []
const failures = []
Promise.allSettled(promises)
.then(data => {
if (data.every(result => result.status === 'fulfilled')) {
res.status(201).send({
message: "All ranks were created successfully.",
successes: data.map(result => result.value),
failures: [],
})
return;
}
data.forEach(result => {
if (result.status === 'fulfilled') {
successes.push(result.value)
} else {
failures.push(result.reason.errors)
}
})
if (successes.length === 0) {
res.status(500).send({
message:
"Failed to create any Ranks.",
failures: failures,
successes: successes,
});
return;
}
res.status(207).send({
message: "Some Ranks were created successfully.",
successes: successes,
failures: failures,
})
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while creating the Rank.",
});
});
};

260
api/db/index.js Normal file
View File

@@ -0,0 +1,260 @@
// require dbconfig
const dbConfig = require("./config.js");
// create connection to database
const Sequelize = require("sequelize");
const sequelize = new Sequelize(
dbConfig.DATABASE,
dbConfig.USER,
dbConfig.PASSWORD,
{
host: dbConfig.HOST,
port: dbConfig.PORT,
dialect: dbConfig.dialect,
pool: {
max: dbConfig.pool.max,
min: dbConfig.pool.min,
acquire: dbConfig.pool.acquire,
idle: dbConfig.pool.idle
}
}
);
try {
sequelize.authenticate();
} catch (error) {
console.error('Unable to connect to the database:', error);
return
}
const db = {};
db.Sequelize = Sequelize;
db.instance = sequelize;
db.Member = db.instance.define("Member", require("./models/Member.model.js"), { paranoid: true })
db.Award = db.instance.define("Award", require("./models/Award.model.js"), { paranoid: true })
db.Course = db.instance.define("Course", require("./models/Course.model.js"), { paranoid: true })
db.Rank = db.instance.define("Rank", require("./models/Rank.model.js"), { paranoid: true })
db.AwardAction = db.instance.define("AwardAction", require("./junctionModels/AwardAction.js"), { paranoid: true })
db.CourseEventTrainingReport = db.instance.define("CourseEventTrainingReport", require("./junctionModels/CourseEventTrainingReport.model.js"), { paranoid: true })
db.CourseEvent = db.instance.define("CourseEvent", require("./models/CourseEvent.js"), { paranoid: true })
// Members have ranks
db.Rank.hasMany(db.Member, {
as: "members",
foreignKey: "rankId"
})
db.Member.belongsTo(db.Rank, {
as: "rank",
foreignKey: "rankId"
})
// Members have statuses
db.MemberStatus = db.instance.define("MemberStatus", require("./models/MemberStatus.model.js"), { paranoid: true })
db.MemberStatus.hasMany(db.Member, {
as: "members",
foreignKey: "statusId"
})
db.Member.belongsTo(db.MemberStatus, {
as: "status",
foreignKey: "statusId"
})
// * AWARDS
// Awards have a creator
db.Award.belongsTo(db.Member, {
as: "createdBy",
foreignKey: "createdById"
})
db.Member.hasMany(db.Award, {
as: "awardsCreated",
foreignKey: "createdById"
})
// Awards have a last modified by
db.Award.belongsTo(db.Member, {
as: "lastModifiedBy",
foreignKey: "lastModifiedById"
})
db.Member.hasMany(db.Award, {
as: "awardsLastModified",
foreignKey: "lastModifiedById"
})
// Members are granted awards
db.Award.belongsToMany(db.Member, {
through: db.AwardAction,
as: "awardHolders",
foreignKey: "recipientId",
});
db.Member.belongsToMany(db.Award, {
through: db.AwardAction,
as: "awardsEarned",
foreignKey: "awardId"
});
// * AWARDS GRANTED/REVOKED
// Instances of award grants have a creator
db.AwardAction.belongsTo(db.Member, {
as: "createdBy",
foreignKey: "createdById"
})
db.Member.hasMany(db.AwardAction, {
as: "awardGrantsCreated",
foreignKey: "createdById"
})
// Instances of award grants have a last modified by
db.AwardAction.belongsTo(db.Member, {
as: "lastModifiedBy",
foreignKey: "lastModifiedById"
})
db.Member.hasMany(db.AwardAction, {
as: "awardGrantsLastModified",
foreignKey: "lastModifiedById"
})
// Instances of award grants have the acting member
db.AwardAction.belongsTo(db.Member, {
as: "actor",
foreignKey: "actorId"
})
db.Member.hasMany(db.AwardAction, {
as: "awardGrantsGiven",
foreignKey: "actorId"
})
// Instances of award grants have the recipient
db.AwardAction.belongsTo(db.Member, {
as: "recipient",
foreignKey: "recipientId"
})
db.Member.hasMany(db.AwardAction, {
as: "awardGrantsReceived",
foreignKey: "recipientId"
})
// Instances of award grants have the award
db.AwardAction.belongsTo(db.Award, {
as: "award",
foreignKey: "awardId"
})
db.Award.hasMany(db.AwardAction, {
as: "awardGrants",
foreignKey: "awardId"
})
// * COURSE EVENTS
// Events have a creator
db.CourseEvent.belongsTo(db.Member, {
as: "createdBy",
foreignKey: "createdById"
})
db.Member.hasMany(db.CourseEvent, {
as: "courseEventsCreated",
foreignKey: "createdById"
})
// Events have a last modified by
db.CourseEvent.belongsTo(db.Member, {
as: "lastModifiedBy",
foreignKey: "lastModifiedById"
})
db.Member.hasMany(db.CourseEvent, {
as: "courseEventsLastModified",
foreignKey: "lastModifiedById"
})
// Events have a course that's taught
db.CourseEvent.belongsTo(db.Course, {
as: "courseTaught",
foreignKey: "courseTaughtId"
})
db.Course.hasMany(db.CourseEvent, {
as: "trainingsHeld",
foreignKey: "courseTaughtId"
})
// Events have one or more trainer
db.CourseEvent.belongsToMany(db.Member, {
through: "CourseEventsTrainers",
as: "trainers",
foreignKey: "trainerId"
})
db.Member.belongsToMany(db.CourseEvent, {
through: "CourseEventsTrainers",
as: "courseEventsTaught",
foreignKey: "courseEventId"
})
// Events have one or more observer
db.CourseEvent.belongsToMany(db.Member, {
through: "CourseEventsObservers",
as: "observers",
foreignKey: "courseEventId"
})
db.Member.belongsToMany(db.CourseEvent, {
through: "CourseEventsObservers",
as: "courseEventsObserved",
foreignKey: "observerId"
})
// Events have one or more attendees, each of which passed or did not
db.CourseEvent.belongsToMany(db.Member, {
through: db.CourseEventTrainingReport,
as: "attendees",
foreignKey: "attendeeId"
});
db.Member.belongsToMany(db.CourseEvent, {
through: db.CourseEventTrainingReport,
as: "courseEventsAttended",
foreignKey: "courseEventId"
});
// * COURSES
// Courses have a creator
db.Course.belongsTo(db.Member, {
as: "createdBy",
foreignKey: "createdById"
})
db.Member.hasMany(db.Course, {
as: "coursesCreated",
foreignKey: "createdById"
})
// Courses have a last modified by
db.Course.belongsTo(db.Member, {
as: "lastModifiedBy",
foreignKey: "lastModifiedById"
})
db.Member.hasMany(db.Course, {
as: "coursesLastModified",
foreignKey: "lastModifiedById"
})
// Courses have SMEs
db.Course.belongsToMany(db.Member, {
through: "CoursesSME",
as: "sme",
foreignKey: "smeId"
})
db.Member.belongsToMany(db.Course, {
through: "CoursesSME",
as: "coursesSMEFor",
foreignKey: "courseId"
})
// Courses belong to award paths
db.Award.belongsToMany(db.Course, {
through: "CoursesAwards",
as: "coursesRequired",
foreignKey: "courseId"
});
// Awards have pre-requisite courses
db.Course.belongsToMany(db.Award, {
through: "CoursesAwards",
as: "possibleAwards",
foreignKey: "awardId"
});
module.exports = db;

View File

@@ -0,0 +1,34 @@
const Sequelize = require('sequelize');
module.exports = {
actionDate: {
type: Sequelize.DataTypes.DATEONLY,
allowNull: false,
defaultValue: Sequelize.DataTypes.NOW
},
isGrantEvent: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: true,
defaultValue: true
},
actorId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
recipientId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
awardId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
}

View File

@@ -0,0 +1,21 @@
const Sequelize = require('sequelize');
module.exports = {
attendeeId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
courseEventId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
passed: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
notes: {
type: Sequelize.DataTypes.STRING(500),
allowNull: true,
}
}

View File

@@ -0,0 +1,37 @@
const Sequelize = require('sequelize');
module.exports = {
name: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false,
},
shortName: {
type: Sequelize.DataTypes.STRING(70),
allowNull: false
},
description: {
type: Sequelize.DataTypes.STRING(1000),
allowNull: true
},
category: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false
},
imageUrl: {
type: Sequelize.DataTypes.STRING,
allowNull: true,
isUrl: true
},
footprint: {
type: Sequelize.DataTypes.STRING(45),
allowNull: false
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
};

View File

@@ -0,0 +1,32 @@
const Sequelize = require("sequelize");
module.exports = {
name: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false
},
shortName: {
type: Sequelize.DataTypes.STRING(70),
allowNull: false
},
category: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false
},
description: {
type: Sequelize.DataTypes.STRING(1000),
allowNull: true
},
imageUrl: {
type: Sequelize.DataTypes.STRING,
allowNull: true
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
};

View File

@@ -0,0 +1,25 @@
const Sequelize = require('sequelize');
module.exports = {
notes: {
type: Sequelize.DataTypes.STRING(1000),
allowNull: true,
},
runDate: {
type: Sequelize.DataTypes.DATEONLY,
allowNull: false,
defaultValue: Sequelize.DataTypes.NOW
},
courseTaughtId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
}

View File

@@ -0,0 +1,53 @@
const Sequelize = require('sequelize');
module.exports = {
name: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false,
unique: true
},
email: {
type: Sequelize.DataTypes.STRING(100),
allowNull: true,
// validate: {
// isEmail: true
// }
},
password: {
type: Sequelize.DataTypes.STRING(100),
allowNull: true
},
website: {
type: Sequelize.DataTypes.STRING(240),
allowNull: true,
// validate: {
// isUrl: true
// }
},
steamId64: {
type: Sequelize.DataTypes.STRING(17),
allowNull: true,
unique: true
},
steamProfileName: {
type: Sequelize.DataTypes.STRING(32),
allowNull: true
},
discordId: {
type: Sequelize.DataTypes.STRING(18),
unique: true,
allowNull: true
},
discordUsername: {
type: Sequelize.DataTypes.STRING(32),
allowNull: true
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
};

View File

@@ -0,0 +1,16 @@
const Sequelize = require('sequelize');
module.exports = {
name: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false,
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
};

View File

View File

@@ -0,0 +1,35 @@
const Sequelize = require('sequelize');
module.exports = {
name: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false,
unique: true
},
shortName: {
type: Sequelize.DataTypes.STRING(70),
allowNull: false
},
category: {
type: Sequelize.DataTypes.STRING(100),
allowNull: false
},
sortId: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
imageUrl: {
type: Sequelize.DataTypes.STRING(240),
allowNull: true,
isUrl: true
},
createdById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
lastModifiedById: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
},
};

View File

@@ -0,0 +1,98 @@
const award = require("../controllers/Award.controller.js");
const db = require("..");
var router = require("express").Router();
// Create a new Award
router.post("/", award.create);
// GET AWARD
router.get("/", async (req, res) => {
const id = req.query.id;
if (!id) {
return db.Award.findAll()
.then(results => res.send(results))
}
return db.Award.findByPk(id)
.then(async (award) => {
if (award === null) {
res.status(404).send({
message: `Award with id=${id} was not found!`
});
return
}
res.send(award)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving awards."
})
})
});
// GET AWARD DETAILS
router.get("/details", async (req, res) => {
const id = req.query.id;
if (!id) {
res.status(400).send({
message: "Award id cannot be empty!"
});
return
}
return db.Award.findByPk(id, {
include: [
'awardHolders',
'coursesRequired'
]
})
.then(async (award) => {
if (award === null) {
res.status(404).send({
message: `Award with id=${id} was not found!`
});
return
}
res.send(award)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving awards."
})
})
});
// GET CATEGORIES
router.get("/categories", async (req, res) => {
return db.Award.findAll({
attributes: ['category'],
group: ['category']
})
.then(async (awardCategories) => {
if (awardCategories === null) {
res.status(404).send({
message: `Award categories were not found!`
});
return
}
res.send(awardCategories.map(awardCategory => awardCategory.category))
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving award categories."
})
})
});
module.exports = {
apiPath: "/api/awards",
apiRouter: router
};

View File

@@ -0,0 +1,96 @@
const courses = require("../controllers/Course.controller.js");
const db = require("..");
var router = require("express").Router();
// Create a new Course
router.post("/", courses.create);
// GET AWARD
router.get("/", async (req, res) => {
const id = req.query.id;
if (!id) {
return db.Course.findAll()
.then(results => res.send(results))
}
return db.Course.findByPk(id)
.then(async (course) => {
if (course === null) {
res.status(404).send({
message: `Course with id=${id} was not found!`
});
return
}
res.send(course)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving courses."
})
})
});
// GET AWARD DETAILS
router.get("/details", async (req, res) => {
const id = req.query.id;
if (!id) {
res.status(400).send({
message: "Course id cannot be empty!"
});
return
}
return db.Course.findByPk(id, {
include: [
'trainingsHeld',
'possibleAwards',
'sme'
]
})
.then(async (course) => {
if (course === null) {
res.status(404).send({
message: `Course with id=${id} was not found!`
});
return
}
res.send(course)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving courses."
})
})
});
// GET CATEGORIES
router.get("/categories", async (req, res) => {
return db.Course.findAll({
attributes: ['category'],
group: ['category']
})
.then(async (courseCategories) => {
if (courseCategories === null) {
res.status(404).send({
message: `Course categories were not found!`
});
return
}
res.send(courseCategories.map(courseCategory => courseCategory.category))
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving course categories."
})
})
});
module.exports = {
apiPath: "/api/courses",
apiRouter: router
};

View File

@@ -0,0 +1,231 @@
const member = require("../controllers/Member.controller.js");
const db = require("..");
var router = require("express").Router();
// Create a new Member
router.post("/", member.create);
// Retrieve all Members
router.get("/", member.findAll);
// GET MEMBER
router.get("/:id", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
res.send(member)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
})
})
});
// GET MEMBER DETAILS
router.get("/:id/details", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id, {
include: [
'rank',
'status',
'awards',
'coursesSMEFor',
'coursesTaught',
'coursesAttended'
]
})
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
res.send(member)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
})
})
});
// COURSES TAUGHT
router.get("/:id/courses/taught", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
const courses = await member.getCoursesSMEFor()
res.send(courses)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
})
})
});
// COURSES ATTENDED
router.get("/:id/courses/attended", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
const courses = await member.getCoursesAttended()
res.send(courses)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
})
})
});
// COURSES SME FOR
router.get("/:id/courses/sme", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
const courses = await member.getCoursesSMEFor()
res.send(courses)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members."
})
})
});
// UPDATE MEMBER
router.put("/:id", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
member.set(req.body)
await member.save()
res.send(member)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members.",
error: err
})
})
});
// Delete a Member with id
router.delete("/:id", async (req, res) => {
const id = req.params.id;
if (!id) {
res.status(400).send({
message: "Member id cannot be empty!"
});
return
}
return db.Member.findByPk(id)
.then(async (member) => {
if (member === null) {
res.status(404).send({
message: `Member with id=${id} was not found!`
});
return
}
await member.destroy()
res.send({
deleted: member,
message: `Member with id=${id} was deleted!`
})
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving members.",
error: err
})
})
});
// Delete all Members
// router.delete("/", member.deleteAll);
module.exports = {
apiPath: "/api/members",
apiRouter: router
};

View File

@@ -0,0 +1,95 @@
const rank = require("../controllers/Rank.controller.js");
const db = require("..");
var router = require("express").Router();
// Create a new Rank
router.post("/", rank.create);
// GET RANK
router.get("/", async (req, res) => {
const id = req.query.id;
if (!id) {
return db.Rank.findAll()
.then(results => res.send(results))
}
return db.Rank.findByPk(id)
.then(async (rank) => {
if (rank === null) {
res.status(404).send({
message: `Rank with id=${id} was not found!`
});
return
}
res.send(rank)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving ranks."
})
})
});
// GET RANK DETAILS
router.get("/details", async (req, res) => {
const id = req.query.id;
if (!id) {
res.status(400).send({
message: "Rank id cannot be empty!"
});
return
}
return db.Rank.findByPk(id, {
include: [
'members',
]
})
.then(async (rank) => {
if (rank === null) {
res.status(404).send({
message: `Rank with id=${id} was not found!`
});
return
}
res.send(rank)
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving ranks."
})
})
});
// GET CATEGORIES
router.get("/categories", async (req, res) => {
return db.Rank.findAll({
attributes: ['category'],
group: ['category']
})
.then(async (rankCategories) => {
if (rankCategories === null) {
res.status(404).send({
message: `Rank categories were not found!`
});
return
}
res.send(rankCategories.map(rankCategory => rankCategory.category))
})
.catch(err => {
res.status(500).send({
message:
err.message || "Some error occurred while retrieving rank categories."
})
})
});
module.exports = {
apiPath: "/api/ranks",
apiRouter: router
};

2529
api/db/sequelize-docgen.js Normal file

File diff suppressed because it is too large Load Diff

103
api/index.js Normal file
View File

@@ -0,0 +1,103 @@
// set up a basic API for MySQL
// this is a simple API that will allow us to do basic CRUD operations on our MySQL database
// import the express module
const express = require('express');
const cors = require('cors')
// create an express app
const app = express();
// enable cors
var corsOptions = {
// origin: "http://localhost:5173"
};
// app.use(cors(corsOptions));
app.use(cors());
// parse requests of content-type - application/json
app.use(express.json());
// parse requests of content-type - application/x-www-form-urlencoded
app.use(express.urlencoded({ extended: true }));
// include all routes
const os = require('os');
const fs = require('fs');
const path = require('path');
const routesPath = path.join(__dirname, 'db/routes');
const routeFiles = fs.readdirSync(routesPath);
// auth providers
const bearerToken = require('express-bearer-token');
// const { expressjwt: jwt } = require("express-jwt");
for (const file of routeFiles) {
const { apiPath, apiRouter } = require(path.join(routesPath, file));
app.use(
apiPath,
// JWT Bearer token
// jwt({
// secret: process.env.JWT_SECRET,
// algorithms: ["HS256"],
// }),
// function (err, req, res, next) {
// if (err.name === "UnauthorizedError") {
// res.status(401).send("invalid token!");
// } else {
// next(err);
// }
// },
// simple plaintext Bearer token
bearerToken(),
function (req, res, next) {
if (req.token === process.env.BEARER_TOKEN) {
next();
} else {
res.status(401).send("invalid token!");
}
},
apiRouter
);
}
// sync/init database
const db = require('./db');
const dbConfig = require("./db/config.js");
// if database doesn't exist, create it
db.instance.query('CREATE DATABASE IF NOT EXISTS ' + dbConfig.DATABASE)
.then(() => {
console.log('Database created')
db.instance.sync({
force: true
});
})
.then(() => {
// configure api docs
const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');
const swaggerDocument = YAML.load('openapi.yaml');
// local YAML
// app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, {
// explorer: true
// }));
// converted from Postman
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, {
explorer: true
}));
Promise.resolve()
}).then(() => {
// set port, listen for requests
app.listen(3000, function () {
console.log('App running on port ' + 3000);
});
}).catch((err) => {
console.log(err);
});

409
api/openapi.json Normal file
View File

@@ -0,0 +1,409 @@
{
"openapi": "3.0.0",
"info": {
"title": "17th Rangers Database API",
"description": "An API for the 17th Rangers Database",
"contact": {
"email": "indigo@indigofox.dev",
"name": "Indigo Fox"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/license/mit/"
},
"version": "0.0.1"
},
"servers": [
{
"url": "http://localhost:3001/api",
"description": "Development"
},
{
"url": "https://indigofox.dev:9230/api",
"description": "Production"
}
],
"tags": [
{
"name": "members",
"description": "Operations on users/members"
},
{
"name": "ranks",
"description": "Rank information & related categories"
},
{
"name": "awards",
"description": "Badges & ribbons"
},
{
"name": "courses",
"description": "Training courses"
},
{
"name": "courseEvents",
"description": "Instances of trainings held for specific courses"
},
{
"name": "member statuses",
"description": "Member status indicating active, inactive, company membership, etc."
}
],
"paths": {
"/members": {
"get": {
"tags": [
"members"
],
"summary": "Get all members",
"description": "Returns a list of all members",
"responses": {
"200": {
"description": "A list of members",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Member"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
},
"post": {
"tags": [
"members"
],
"summary": "Create a member",
"description": "Creates a new member",
"requestBody": {
"description": "Member object that needs to be added",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberPut"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Member created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Member"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/ClientInputError"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
}
},
"/members/{memberId}": {
"get": {
"tags": [
"members"
],
"summary": "Get a member by ID",
"description": "Returns a single member",
"parameters": [
{
"name": "memberId",
"in": "path",
"description": "ID of member to return",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "Member found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Member"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
},
"put": {
"tags": [
"members"
],
"summary": "Update a member by ID",
"description": "Updates a member",
"parameters": [
{
"name": "memberId",
"in": "path",
"description": "ID of member to update",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"requestBody": {
"description": "Member object that needs to be updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberPut"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Member updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Member"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/ClientInputError"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
}
},
"/training-reports": {
"get": {
"tags": [
"training-reports"
],
"summary": "Get all training reports",
"description": "Returns a list of all training reports",
"responses": {
"200": {
"description": "A list of training reports",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrainingReport"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
},
"post": {
"tags": [
"training-reports"
],
"summary": "Create a training report",
"description": "Creates a new training report",
"requestBody": {
"$ref": "#/components/requestBodies/CourseEventTrainingReport",
"required": true
},
"responses": {
"201": {
"description": "Training report created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrainingReport"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/ClientInputError"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/responses/InternalServerError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Member": {
"$ref": "schemas/member.json"
},
"MemberPut": {
"$ref": "schemas/memberPut.json"
},
"Rank": {
"$ref": "schemas/rank.json"
},
"Award": {
"$ref": "schemas/award.json"
},
"AwardAction": {
"$ref": "schemas/awardAction.json"
},
"CourseEvent": {
"$ref": "schemas/courseEvent.json"
},
"Error": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"description": "A human readable error message",
"type": "string"
}
}
}
},
"requestBodies": {
"CourseEventTrainingReport": {
"$ref": "requestBodies/courseEventTrainingReport.json"
}
},
"responses": {
"Unauthorized": {
"description": "Unauthorized"
},
"ClientInputError": {
"description": "Client Input Error"
},
"NotFound": {
"description": "Not Found"
},
"InternalServerError": {
"description": "Internal Server Error"
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "plain-text"
}
}
},
"security": [
{
"bearerAuth": []
}
]
}

1565
api/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

1564
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
api/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon --legacy-watch -e js,json,yaml index.js",
"prod": "node index.js"
},
"author": "IndigoFox",
"license": "ISC",
"dependencies": {
"axios": "^1.3.4",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-bearer-token": "^2.4.0",
"express-jwt": "^8.4.1",
"mysql2": "^3.2.0",
"nodemon": "^2.0.22",
"sequelize": "^6.29.3",
"swagger-ui-express": "^4.6.2",
"yamljs": "^0.3.0"
}
}

134
defaults/ranks.json Normal file
View File

@@ -0,0 +1,134 @@
[
{
"name": "Recruit",
"category": "Enlisted",
"sort_id": 22,
"image_url": "https://i.imgur.com/UE1Zs6g.png"
},
{
"name": "Private",
"category": "Enlisted",
"sort_id": 21,
"image_url": "http://i.imgur.com/Wh4nYns.png"
},
{
"name": "Private First Class",
"category": "Enlisted",
"sort_id": 20,
"image_url": "http://i.imgur.com/9V9PBDi.png"
},
{
"name": "Specialist",
"category": "Enlisted",
"sort_id": 19,
"image_url": "http://i.imgur.com/jEEuKKB.png"
},
{
"name": "Corporal",
"category": "NCO",
"sort_id": 18,
"image_url": "http://i.imgur.com/nfZrieG.png"
},
{
"name": "Sergeant",
"category": "NCO",
"sort_id": 17,
"image_url": "http://i.imgur.com/hfGy0ZZ.png"
},
{
"name": "Staff Sergeant",
"category": "NCO",
"sort_id": 16,
"image_url": "http://i.imgur.com/ZVg95ep.png"
},
{
"name": "Sergeant 1st Class",
"category": "NCO",
"sort_id": 15,
"image_url": ""
},
{
"name": "Master Sergeant",
"category": "NCO",
"sort_id": 14,
"image_url": ""
},
{
"name": "1st Sergeant",
"category": "NCO",
"sort_id": 13,
"image_url": ""
},
{
"name": "Sergeant Major",
"category": "NCO",
"sort_id": 12,
"image_url": ""
},
{
"name": "Warrant Officer 1",
"category": "Enlisted",
"sort_id": 11,
"image_url": ""
},
{
"name": "Chief Warrant Officer 2",
"category": "Enlisted",
"sort_id": 10,
"image_url": ""
},
{
"name": "Chief Warrant Officer 3",
"category": "NCO",
"sort_id": 9,
"image_url": ""
},
{
"name": "Chief Warrant Officer 4",
"category": "NCO",
"sort_id": 8,
"image_url": ""
},
{
"name": "Chief Warrant Officer 5",
"category": "NCO",
"sort_id": 7,
"image_url": ""
},
{
"name": "2nd Lieutenant",
"category": "Officer",
"sort_id": 6,
"image_url": ""
},
{
"name": "1st Lieutenant",
"category": "Officer",
"sort_id": 5,
"image_url": ""
},
{
"name": "Captain",
"category": "Officer",
"sort_id": 4,
"image_url": ""
},
{
"name": "Major",
"category": "Officer",
"sort_id": 3,
"image_url": ""
},
{
"name": "Lieutenant Colonel",
"category": "Officer",
"sort_id": 2,
"image_url": ""
},
{
"name": "Staff",
"category": "",
"sort_id": 1,
"image_url": ""
}
]

63
docker-compose.yaml Normal file
View File

@@ -0,0 +1,63 @@
version: '3.8'
name: 17thrangers
services:
db:
env_file: .env
image: mysql
# NOTE: use of "mysql_native_password" is not recommended: https://dev.mysql.com/doc/refman/8.0/en/upgrading-from-previous-series.html#upgrade-caching-sha2-password
# (this is just an example, not intended to be a production configuration)
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./mysql:/var/lib/mysql
ports:
- 12730:3306
api:
env_file: .env
build:
context: ./api
dockerfile: Dockerfile
# use bind mount - make sure you go to ./api and run npm i first
volumes:
- ./api:/app
command: npm run dev
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
restart: always
ports:
- 3000:3000
depends_on:
- db
# get self-signed cert
nginx:
env_file: .env
image: nginx:latest
restart: always
ports:
- 9229:9229
- 9230:9230
volumes:
- ./nginx/conf/:/etc/nginx/conf.d
# - ./certbot/www:/var/www/certbot/:ro
# - ./certbot/conf/:/etc/nginx/ssl/:ro
# my implementation, already have another primary instance
- /home/certbot/www:/var/www/certbot/:ro
- /home/certbot/conf/:/etc/nginx/ssl/:ro
depends_on:
- api
certbot:
image: certbot/certbot:latest
volumes:
- ./certbot/www/:/var/www/certbot/:rw
- ./certbot/conf/:/etc/letsencrypt/:rw

1
mysql/.gitkeep Normal file
View File

@@ -0,0 +1 @@
{\rtf1}

40
nginx/conf/api.conf Normal file
View File

@@ -0,0 +1,40 @@
server {
listen 9229;
listen [::]:9229;
server_name example.org;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
# redirect to 9230
return 301 https://example.org:9230$request_uri;
}
}
server {
listen 9230 default_server ssl http2;
listen [::]:9230 ssl http2;
server_name example.org;
ssl_certificate /etc/nginx/ssl/live/example.org/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/example.org/privkey.pem;
location / {
# send to api container
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

9
nginx/conf/certmgmt.txt Normal file
View File

@@ -0,0 +1,9 @@
# DRY RUN - ensure certificates CAN be created
docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot/ --dry-run -d example.org
# PROD RUN - generate certificates for the provided site
docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot/ -d example.org
# RENEW CERTIFICATES - run every 3 months
docker compose run --rm certbot renew