├── .dockerignore ├── .github └── workflows │ ├── test-console.yml │ ├── test-frontend.yml │ └── test-server.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.prebuilt ├── LICENSE ├── README.md ├── console ├── .eslintrc.json ├── .gitignore ├── .idea │ ├── codeStyles │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── console.iml │ ├── inspectionProfiles │ │ └── Project_Default.xml │ ├── modules.xml │ └── vcs.xml ├── .nvmrc ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components.js │ ├── components │ │ ├── BackButton.vue │ │ ├── ConnectionBanner.vue │ │ ├── DateTimePicker.vue │ │ ├── DisplayIcon.vue │ │ ├── EditableText.vue │ │ ├── ErrorMessage.vue │ │ ├── Navbar.vue │ │ ├── admin │ │ │ ├── ApiKeyEditor.vue │ │ │ ├── UserEditor.vue │ │ │ └── settings │ │ │ │ ├── CoordinateValue.vue │ │ │ │ ├── IntegerValue.vue │ │ │ │ └── StringValue.vue │ │ ├── content │ │ │ ├── AnnouncementCard.vue │ │ │ ├── AnnouncementEditor.vue │ │ │ └── CalendarFeedForm.vue │ │ ├── displays │ │ │ ├── DisplayCard.vue │ │ │ └── DisplayEditor.vue │ │ └── views │ │ │ ├── PreviewContentSlot.vue │ │ │ ├── ViewEditForm.vue │ │ │ ├── ViewList.vue │ │ │ ├── ViewListItem.vue │ │ │ ├── ViewPreview.vue │ │ │ └── editor │ │ │ ├── AnnouncementListOptions.vue │ │ │ ├── ContentSlot.vue │ │ │ ├── DWDWarningMapOptions.vue │ │ │ ├── GridEditor.vue │ │ │ └── RemoteImageOptions.vue │ ├── feathers-client.js │ ├── font-awesome.js │ ├── main.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── index.js │ │ ├── services │ │ │ ├── announcements.js │ │ │ ├── api-keys.js │ │ │ ├── calendar-feeds.js │ │ │ ├── content-slots.js │ │ │ ├── displays.js │ │ │ ├── incidents.js │ │ │ ├── key-requests.js │ │ │ ├── locations.js │ │ │ ├── settings.js │ │ │ ├── users.js │ │ │ └── views.js │ │ ├── socket.js │ │ └── store.auth.js │ └── views │ │ ├── About.vue │ │ ├── Login.vue │ │ ├── Overview.vue │ │ ├── Setup.vue │ │ ├── admin │ │ ├── ApiKeyForm.vue │ │ ├── ApiKeyList.vue │ │ ├── Settings.vue │ │ ├── UserForm.vue │ │ └── UserList.vue │ │ ├── content │ │ ├── AnnouncementForm.vue │ │ ├── AnnouncementList.vue │ │ ├── CalendarFeedEditor.vue │ │ └── CalendarsView.vue │ │ └── displays │ │ ├── DisplayForm.vue │ │ ├── DisplayList.vue │ │ ├── KeyRequestCard.vue │ │ └── ViewForm.vue └── vue.config.js ├── frontend ├── .eslintrc.json ├── .gitignore ├── .idea │ ├── codeStyles │ │ └── codeStyleConfig.xml │ ├── frontend.iml │ ├── inspectionProfiles │ │ └── Project_Default.xml │ ├── modules.xml │ └── vcs.xml ├── .nvmrc ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ ├── AlertBanner.vue │ │ ├── AlertScreen.vue │ │ ├── BrightSky.vue │ │ ├── Clock.vue │ │ ├── ConnectionBanner.vue │ │ ├── DWDWarningMap.vue │ │ ├── DefaultAlertView.vue │ │ ├── DisplayApp.vue │ │ ├── DisplaySetup.vue │ │ ├── GridView.vue │ │ ├── GridViewComponent.vue │ │ ├── IdleScreen.vue │ │ ├── NextUpList.vue │ │ ├── NextUpListItem.vue │ │ ├── RemoteImage.vue │ │ ├── SplashScreen.vue │ │ └── announcements │ │ │ ├── AnnouncementList.vue │ │ │ └── Item.vue │ ├── feathers-client.js │ ├── font-awesome.js │ ├── main.js │ └── store │ │ ├── index.js │ │ ├── services │ │ ├── announcements.js │ │ ├── calendar-items.js │ │ ├── content-slots.js │ │ ├── displays.js │ │ ├── incidents.js │ │ ├── key-requests.js │ │ ├── locations.js │ │ ├── settings.js │ │ └── views.js │ │ └── socket.js └── vue.config.js ├── renovate.json ├── scripts └── build.sh ├── server ├── .codeclimate.yml ├── .editorconfig ├── .env.example ├── .gitignore ├── .idea │ ├── codeStyles │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── inspectionProfiles │ │ └── Project_Default.xml │ ├── jsLibraryMappings.xml │ ├── modules.xml │ ├── server.iml │ └── vcs.xml ├── .nvmrc ├── README.md ├── config │ ├── ci.json │ ├── default.json │ ├── development.json │ ├── docker.json │ ├── production.json │ └── test.json ├── eslint.config.mjs ├── jest.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── style.css ├── src │ ├── app.hooks.ts │ ├── app.ts │ ├── auth-strategies │ │ └── api-key.strategy.ts │ ├── authentication.ts │ ├── channels.ts │ ├── declarations.d.ts │ ├── hooks │ │ ├── allowApiKey.ts │ │ └── unserializeJson.ts │ ├── index.ts │ ├── logger.ts │ ├── middleware │ │ └── index.ts │ ├── migrations │ │ ├── 00000_create-users.ts │ │ ├── 00001_create-displays.ts │ │ ├── 00002_create-views.ts │ │ ├── 00003_create-content-slots.ts │ │ ├── 00004_create-content-slot-options.ts │ │ ├── 00005_create-api-keys.ts │ │ ├── 00006_create-incidents.ts │ │ ├── 00007_create-locations.ts │ │ ├── 00008_create-announcements.ts │ │ ├── 00009_fix-locations-constraint.ts │ │ ├── 00010_extend_incident_description.ts │ │ ├── 00011_create-settings.ts │ │ ├── 00012_move_content-slot-options.ts │ │ ├── 00013_add-display-type.ts │ │ ├── 00014_split-locality.ts │ │ ├── 00015_create-calendar-feeds.ts │ │ ├── 00016_add-view-state.ts │ │ └── 00017_add-view-pinned.ts │ ├── models │ │ ├── announcements.model.ts │ │ ├── api-keys.model.ts │ │ ├── calendar-feeds.model.ts │ │ ├── content-slots.model.ts │ │ ├── displays.model.ts │ │ ├── incidents.model.ts │ │ ├── locations.model.ts │ │ ├── settings.model.ts │ │ ├── users.model.ts │ │ └── views.model.ts │ ├── sequelize.ts │ └── services │ │ ├── announcements │ │ ├── announcements.class.ts │ │ ├── announcements.hooks.ts │ │ └── announcements.service.ts │ │ ├── api-keys │ │ ├── api-keys.class.ts │ │ ├── api-keys.hooks.ts │ │ └── api-keys.service.ts │ │ ├── calendar-feeds │ │ ├── calendar-feeds.class.ts │ │ ├── calendar-feeds.hooks.ts │ │ └── calendar-feeds.service.ts │ │ ├── calendar-items │ │ ├── calendar-items.class.ts │ │ ├── calendar-items.hooks.ts │ │ └── calendar-items.service.ts │ │ ├── content-slots │ │ ├── content-slots.class.ts │ │ ├── content-slots.hooks.ts │ │ └── content-slots.service.ts │ │ ├── displays │ │ ├── displays.class.ts │ │ ├── displays.hooks.ts │ │ └── displays.service.ts │ │ ├── hub-connector │ │ ├── hub-connector.class.ts │ │ ├── hub-connector.hooks.ts │ │ ├── hub-connector.service.ts │ │ └── services │ │ │ ├── incidents.class.ts │ │ │ └── locations.class.ts │ │ ├── incidents │ │ ├── incidents.class.ts │ │ ├── incidents.hooks.ts │ │ └── incidents.service.ts │ │ ├── index.ts │ │ ├── key-requests │ │ ├── key-requests.class.ts │ │ ├── key-requests.hooks.ts │ │ └── key-requests.service.ts │ │ ├── locations │ │ ├── locations.class.ts │ │ ├── locations.hooks.ts │ │ └── locations.service.ts │ │ ├── settings │ │ ├── settings.class.ts │ │ ├── settings.hooks.ts │ │ └── settings.service.ts │ │ ├── status │ │ ├── status.class.ts │ │ ├── status.hooks.ts │ │ └── status.service.ts │ │ ├── users │ │ ├── users.class.ts │ │ ├── users.hooks.ts │ │ └── users.service.ts │ │ └── views │ │ ├── views.class.ts │ │ ├── views.hooks.ts │ │ └── views.service.ts ├── test │ ├── app.test.ts │ ├── authentication.test.ts │ ├── eslint.config.mjs │ ├── feed.ics │ ├── services │ │ ├── announcements.test.ts │ │ ├── api-keys.test.ts │ │ ├── calendar-feeds.test.ts │ │ ├── calendar-items.test.ts │ │ ├── content-slots.test.ts │ │ ├── displays.test.ts │ │ ├── hub-connector.test.ts │ │ ├── incidents.test.ts │ │ ├── key-requests.test.ts │ │ ├── locations.test.ts │ │ ├── settings.test.ts │ │ ├── status.test.ts │ │ ├── users.test.ts │ │ └── views.test.ts │ └── testSequencer.js ├── tsconfig.json └── typings │ ├── feathers-shallow-populate │ └── index.d.ts │ └── ical.js │ └── index.d.ts └── test-api ├── .gitignore ├── .idea ├── .gitignore ├── modules.xml ├── test-api.iml └── vcs.xml ├── .mocharc.json ├── fixtures.mjs ├── package-lock.json ├── package.json └── spec └── 001-authentication ├── common.js ├── key-requests.js └── users.js /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | server/config/local*.json -------------------------------------------------------------------------------- /.github/workflows/test-console.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI for Console 5 | 6 | on: 7 | push: 8 | branches: [ "develop" ] 9 | pull_request: 10 | branches: [ "develop" ] 11 | 12 | defaults: 13 | run: 14 | working-directory: ./console 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 24 | with: 25 | node-version-file: 'console/.nvmrc' 26 | cache: 'npm' 27 | cache-dependency-path: console/package-lock.json 28 | - run: npm ci 29 | - run: npm run lint 30 | - run: npm run build 31 | -------------------------------------------------------------------------------- /.github/workflows/test-frontend.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI for Frontend 5 | 6 | on: 7 | push: 8 | branches: [ "develop" ] 9 | pull_request: 10 | branches: [ "develop" ] 11 | 12 | defaults: 13 | run: 14 | working-directory: ./frontend 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 24 | with: 25 | node-version-file: 'frontend/.nvmrc' 26 | cache: 'npm' 27 | cache-dependency-path: frontend/package-lock.json 28 | - run: npm ci 29 | - run: npm run lint 30 | - run: npm run build 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.19.2@sha256:34ebf3e5d4a51d4b1af4b78188f9b0ad0daf1d763395e2390ab935f910350b78 as build-console 2 | 3 | WORKDIR /home/node/app/console 4 | COPY ./console/package.json ./console/package-lock.json /home/node/app/console/ 5 | RUN npm ci --no-audit 6 | COPY ./console /home/node/app/console 7 | RUN npm run build 8 | 9 | FROM node:20.19.2@sha256:34ebf3e5d4a51d4b1af4b78188f9b0ad0daf1d763395e2390ab935f910350b78 as build-frontend 10 | 11 | WORKDIR /home/node/app/frontend 12 | COPY ./frontend/package.json ./frontend/package-lock.json /home/node/app/frontend/ 13 | RUN npm ci --no-audit 14 | COPY ./frontend /home/node/app/frontend 15 | RUN npm run build 16 | 17 | FROM node:20.19.2@sha256:34ebf3e5d4a51d4b1af4b78188f9b0ad0daf1d763395e2390ab935f910350b78 as build-server 18 | 19 | WORKDIR /home/node/app/server 20 | COPY ./server/package.json ./server/package-lock.json /home/node/app/server/ 21 | RUN npm ci --no-audit 22 | COPY ./server /home/node/app/server 23 | RUN npm run compile 24 | 25 | FROM node:20.19.2@sha256:34ebf3e5d4a51d4b1af4b78188f9b0ad0daf1d763395e2390ab935f910350b78 26 | WORKDIR /home/node/app/ 27 | COPY ./server/package.json ./server/package-lock.json /home/node/app/ 28 | RUN npm ci --only=production --no-audit 29 | COPY --from=build-server /home/node/app/server/lib /home/node/app/ 30 | COPY ./server/config /home/node/app/config 31 | COPY ./server/public /home/node/app/public 32 | COPY --from=build-console /home/node/app/console/dist /home/node/app/ext-console 33 | COPY --from=build-frontend /home/node/app/frontend/dist /home/node/app/ext-display 34 | 35 | EXPOSE 3031 36 | ENV NODE_ENV production 37 | ENV NODE_CONFIG_ENV docker 38 | USER node 39 | CMD [ "node", "index.js" ] 40 | -------------------------------------------------------------------------------- /Dockerfile.prebuilt: -------------------------------------------------------------------------------- 1 | FROM node:20.19.2@sha256:34ebf3e5d4a51d4b1af4b78188f9b0ad0daf1d763395e2390ab935f910350b78 2 | 3 | WORKDIR /home/node/app 4 | COPY ./build/package.json ./build/package-lock.json /home/node/app/ 5 | RUN npm ci --only=production --no-audit 6 | COPY ./build /home/node/app 7 | 8 | EXPOSE 3031 9 | ENV NODE_ENV production 10 | ENV NODE_CONFIG_ENV docker 11 | USER node 12 | CMD [ "node", "index.js" ] 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alarmdisplay Display 2 | 3 | This component takes care of displaying incident data in case of an emergency, and shows general information otherwise. The incidents can be created via API, or synced over from a [Hub](https://github.com/alarmdisplay/hub) instance. 4 | 5 | This repository contains backend and frontend code. 6 | For more info on how to run those parts, check out the README in the respective sub folder. 7 | 8 | ## Build 9 | You can use the [build script](./scripts/build.sh) to create a runnable version of this project. 10 | During this process, the single components in the sub folders should not be running in development mode. 11 | 12 | ### Docker 13 | There are two options to build a Docker image: 14 | * The default [Dockerfile](./Dockerfile) uses multi-stage builds to build everything inside Docker. This might require more resources, but removes the need to have Node.js set up. 15 | * If you built the application with the build script above, you can use the file [Dockerfile.prebuilt](./Dockerfile.prebuilt) to build the image with the contents of the build folder. 16 | 17 | ## Deployment 18 | At the moment, this project is not recommended for deployment outside a development or test environment. 19 | -------------------------------------------------------------------------------- /console/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:vue/essential", 8 | "eslint:recommended" 9 | ], 10 | "parserOptions": { 11 | "parser": "@babel/eslint-parser" 12 | }, 13 | "reportUnusedDisableDirectives": true, 14 | "rules": { 15 | "vue/multi-word-component-names": "off", 16 | "vue/no-mutating-props": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /console/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea/workspace.xml 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /console/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 16 | 17 | 34 | 35 | -------------------------------------------------------------------------------- /console/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /console/.idea/console.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /console/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /console/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /console/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /console/.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.2 2 | -------------------------------------------------------------------------------- /console/README.md: -------------------------------------------------------------------------------- 1 | # Display Console 2 | 3 | This web application offers a user interface to manage the [Display Server](../server). 4 | 5 | ## Development 6 | In order to run a development version on your local system, you need a [Node.js](https://nodejs.org/) environment. 7 | Clone the repository and run `npm install` in this folder to install all the dependencies. 8 | 9 | Start the development server by running `npm run serve`, it will automatically reload when files have changed. 10 | Now you can access the server on http://localhost:8080 (may be a different port on your system, check the console output). 11 | If you run a development Display Server on http://localhost:3031, the requests are automatically proxied there. 12 | This allows for parallel development of the Console and the backend. 13 | 14 | ### Libraries and frameworks 15 | This project uses the following libraries or frameworks, please refer to their documentation as well. 16 | - [Vue.js](https://vuejs.org/) 17 | - [Vue Router](https://router.vuejs.org/) 18 | - [Vuex](https://vuex.vuejs.org/) 19 | - [FeathersVuex](https://vuex.feathersjs.com/) 20 | - [Font Awesome](https://fontawesome.com/) 21 | 22 | ## Build & Deploy 23 | Run `npm run build`, which compiles and minifies the app for production. 24 | You find the result of the build process in a folder called `dist`. 25 | This folder only contains HTML, CSS, and JS files, which means they can be hosted as static files. 26 | By default, the app expects to be accessible under the path `/console/`. 27 | You can change this behaviour by adapting the `publicPath` in [vue.config.js](vue.config.js). 28 | -------------------------------------------------------------------------------- /console/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /console/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alarmdisplay/display-console", 3 | "version": "1.0.0-beta.5", 4 | "private": false, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "license": "AGPL-3.0-only", 11 | "dependencies": { 12 | "@feathersjs/authentication-client": "4.5.18", 13 | "@feathersjs/feathers": "4.5.17", 14 | "@feathersjs/socketio-client": "4.5.18", 15 | "@fortawesome/fontawesome-svg-core": "6.7.2", 16 | "@fortawesome/free-solid-svg-icons": "6.7.2", 17 | "@fortawesome/vue-fontawesome": "2.0.10", 18 | "@vue/composition-api": "1.7.2", 19 | "bulma": "0.9.4", 20 | "core-js": "3.41.0", 21 | "feathers-hooks-common": "6.1.5", 22 | "feathers-vuex": "3.16.0", 23 | "moment": "2.30.1", 24 | "socket.io-client": "2.5.0", 25 | "vue": "2.6.14", 26 | "vue-moment": "4.1.0", 27 | "vue-router": "3.6.5", 28 | "vuex": "3.6.2" 29 | }, 30 | "devDependencies": { 31 | "@babel/eslint-parser": "7.27.1", 32 | "@vue/cli-plugin-babel": "5.0.8", 33 | "@vue/cli-plugin-eslint": "5.0.8", 34 | "@vue/cli-plugin-router": "5.0.8", 35 | "@vue/cli-service": "5.0.8", 36 | "eslint": "8.57.1", 37 | "eslint-plugin-vue": "9.33.0", 38 | "vue-template-compiler": "2.6.14" 39 | }, 40 | "overrides": { 41 | "feathers-vuex": { 42 | "serialize-error": "11.0.3" 43 | } 44 | }, 45 | "browserslist": [ 46 | "> 1%", 47 | "last 2 versions" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /console/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alarmdisplay/display/833f4b4d27e8292df1f873d1142eba9cad80182d/console/public/favicon.ico -------------------------------------------------------------------------------- /console/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Anzeige – Console 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /console/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 67 | 68 | 73 | -------------------------------------------------------------------------------- /console/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alarmdisplay/display/833f4b4d27e8292df1f873d1142eba9cad80182d/console/src/assets/logo.png -------------------------------------------------------------------------------- /console/src/components.js: -------------------------------------------------------------------------------- 1 | const components = { 2 | AnnouncementList: { 3 | name: 'Bekanntmachungen', 4 | icon: 'bullhorn', 5 | options: true, 6 | }, 7 | BrightSky: { 8 | name: 'Bright Sky', 9 | icon: 'cloud-sun', 10 | options: false, 11 | }, 12 | Clock: { 13 | name: 'Uhr', 14 | icon: 'clock', 15 | options: false, 16 | }, 17 | DWDWarningMap: { 18 | name: 'DWD-Warnkarte', 19 | icon: 'cloud-showers-heavy', 20 | options: true, 21 | }, 22 | NextUpList: { 23 | name: 'Termine', 24 | icon: 'calendar', 25 | options: false, 26 | }, 27 | RemoteImage: { 28 | name: 'Bild', 29 | icon: 'image', 30 | options: true, 31 | }, 32 | } 33 | 34 | export function getAvailableComponentTypes() { 35 | return Object.keys(components); 36 | } 37 | 38 | export function getComponentName(componentType) { 39 | return components[componentType]?.name || componentType; 40 | } 41 | 42 | export function getComponentIcon(componentType) { 43 | return components[componentType]?.icon || 'cube'; 44 | } 45 | 46 | export function isComponentConfigurable(componentType) { 47 | return !!(components[componentType]?.options); 48 | } 49 | 50 | export function isValidComponentType(componentType) { 51 | return getAvailableComponentTypes().includes(componentType); 52 | } 53 | -------------------------------------------------------------------------------- /console/src/components/BackButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 19 | -------------------------------------------------------------------------------- /console/src/components/ConnectionBanner.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | 25 | 28 | -------------------------------------------------------------------------------- /console/src/components/DisplayIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /console/src/components/ErrorMessage.vue: -------------------------------------------------------------------------------- 1 | 18 | 40 | -------------------------------------------------------------------------------- /console/src/components/admin/ApiKeyEditor.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 44 | 45 | 48 | -------------------------------------------------------------------------------- /console/src/components/views/PreviewContentSlot.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 43 | -------------------------------------------------------------------------------- /console/src/components/views/ViewPreview.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 29 | 30 | 39 | -------------------------------------------------------------------------------- /console/src/components/views/editor/AnnouncementListOptions.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /console/src/components/views/editor/DWDWarningMapOptions.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 56 | 57 | 60 | -------------------------------------------------------------------------------- /console/src/components/views/editor/RemoteImageOptions.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /console/src/feathers-client.js: -------------------------------------------------------------------------------- 1 | import feathers from '@feathersjs/feathers' 2 | import socketio from '@feathersjs/socketio-client' 3 | import auth from '@feathersjs/authentication-client' 4 | import io from 'socket.io-client' 5 | import { iff, discard } from 'feathers-hooks-common' 6 | import feathersVuex from 'feathers-vuex' 7 | 8 | const socket = io({ transports: ['websocket'] }) 9 | 10 | const feathersClient = feathers() 11 | .configure(socketio(socket)) 12 | .configure(auth({ storage: window.localStorage, storageKey: 'display-console-jwt' })) 13 | .hooks({ 14 | before: { 15 | all: [ 16 | // Prevent sending local-only properties to the server 17 | iff( 18 | context => ['create', 'update', 'patch'].includes(context.method), 19 | discard('__id', '__isTemp') 20 | ) 21 | ] 22 | } 23 | }) 24 | 25 | export default feathersClient 26 | 27 | // Setting up feathers-vuex 28 | const { makeServicePlugin, makeAuthPlugin, BaseModel, models, FeathersVuex } = feathersVuex( 29 | feathersClient, 30 | { 31 | serverAlias: 'api', 32 | idField: 'id', 33 | whitelist: ['$regex', '$options'] 34 | } 35 | ) 36 | 37 | export { makeAuthPlugin, makeServicePlugin, BaseModel, models, FeathersVuex } 38 | -------------------------------------------------------------------------------- /console/src/font-awesome.js: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core' 2 | import { 3 | faArrowsAlt, 4 | faBars, 5 | faBullhorn, 6 | faCalendar, 7 | faChevronLeft, 8 | faChevronRight, 9 | faClock, 10 | faCloudShowersHeavy, 11 | faCloudSun, 12 | faCog, 13 | faColumns, 14 | faCube, 15 | faCubes, 16 | faDesktop, 17 | faEdit, 18 | faEnvelope, 19 | faExpandAlt, 20 | faFolderTree, 21 | faHome, 22 | faInfoCircle, 23 | faImage, 24 | faKey, 25 | faLock, 26 | faPause, 27 | faPencilAlt, 28 | faPlay, 29 | faPlus, 30 | faQuestionCircle, 31 | faSignOutAlt, 32 | faSpinner, 33 | faStopwatch, 34 | faTabletAlt, 35 | faTimes, 36 | faThumbtack, 37 | faThumbtackSlash, 38 | faTrashAlt, 39 | faUser, 40 | faUserEdit, 41 | faUserMinus, 42 | faUserPlus, 43 | faWrench 44 | } from '@fortawesome/free-solid-svg-icons' 45 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 46 | 47 | library.add( 48 | faArrowsAlt, 49 | faBars, 50 | faBullhorn, 51 | faCalendar, 52 | faChevronLeft, 53 | faChevronRight, 54 | faClock, 55 | faCloudShowersHeavy, 56 | faCloudSun, 57 | faCog, 58 | faColumns, 59 | faCube, 60 | faCubes, 61 | faDesktop, 62 | faEdit, 63 | faEnvelope, 64 | faExpandAlt, 65 | faFolderTree, 66 | faHome, 67 | faImage, 68 | faInfoCircle, 69 | faKey, 70 | faLock, 71 | faPause, 72 | faPencilAlt, 73 | faPlay, 74 | faPlus, 75 | faQuestionCircle, 76 | faSignOutAlt, 77 | faSpinner, 78 | faStopwatch, 79 | faTabletAlt, 80 | faTimes, 81 | faThumbtack, 82 | faThumbtackSlash, 83 | faTrashAlt, 84 | faUser, 85 | faUserEdit, 86 | faUserMinus, 87 | faUserPlus, 88 | faWrench 89 | ) 90 | 91 | export default FontAwesomeIcon 92 | -------------------------------------------------------------------------------- /console/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueCompositionApi from '@vue/composition-api' 3 | Vue.use(VueCompositionApi) 4 | 5 | import App from './App.vue' 6 | import router from './router' 7 | import store from './store' 8 | 9 | import moment from 'moment' 10 | import VueMoment from 'vue-moment' 11 | require('moment/locale/de') 12 | Vue.use(VueMoment, { moment }) 13 | 14 | // Load Font Awesome 15 | import FontAwesomeIcon from './font-awesome' 16 | Vue.component('font-awesome-icon', FontAwesomeIcon) 17 | 18 | Vue.config.productionTip = false 19 | 20 | Vue.filter('durationAsDigits', function (seconds) { 21 | function twoDigits (value) { 22 | let strValue = String(value) 23 | if (strValue.length === 1) { 24 | strValue = `0${value}` 25 | } 26 | return strValue 27 | } 28 | 29 | if (seconds < 3600) { 30 | return `${twoDigits(Math.trunc(seconds / 60))}:${twoDigits(seconds % 60)}` 31 | } else { 32 | const secondsOfHour = seconds % 3600 33 | return `${twoDigits(Math.trunc(seconds / 3600))}:${twoDigits(Math.trunc(secondsOfHour / 60))}:${twoDigits(secondsOfHour % 60)}` 34 | } 35 | }) 36 | 37 | // Set up the Vue root instance 38 | new Vue({ 39 | store, 40 | render: h => h(App), 41 | data: { 42 | seconds: Math.floor(Date.now() / 1000) 43 | }, 44 | mounted () { 45 | setInterval(this.updateSeconds, 1000) 46 | }, 47 | methods: { 48 | updateSeconds () { 49 | this.seconds = Math.floor(Date.now() / 1000) 50 | } 51 | }, 52 | router: router 53 | }).$mount('#app') 54 | -------------------------------------------------------------------------------- /console/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import feathersClient, { FeathersVuex } from '@/feathers-client' 4 | import auth from './store.auth' 5 | 6 | import announcements from './services/announcements' 7 | import apiKeys from './services/api-keys' 8 | import calendarFeeds from './services/calendar-feeds' 9 | import contentSlots from './services/content-slots' 10 | import displays from './services/displays' 11 | import incidents from './services/incidents' 12 | import keyRequests from './services/key-requests' 13 | import locations from './services/locations' 14 | import settings from './services/settings' 15 | import users from './services/users' 16 | import views from './services/views' 17 | import socket, { createSocketPlugin } from '@/store/socket' 18 | 19 | Vue.use(Vuex) 20 | Vue.use(FeathersVuex) 21 | 22 | export default new Vuex.Store({ 23 | state: { 24 | showSetup: false 25 | }, 26 | mutations: { 27 | setShowSetup (state, value) { 28 | state.showSetup = value === true 29 | } 30 | }, 31 | actions: { 32 | }, 33 | modules: { 34 | socket 35 | }, 36 | plugins: [ 37 | auth, 38 | createSocketPlugin(feathersClient.io), 39 | announcements, 40 | apiKeys, 41 | calendarFeeds, 42 | contentSlots, 43 | displays, 44 | incidents, 45 | keyRequests, 46 | locations, 47 | settings, 48 | users, 49 | views 50 | ] 51 | }) 52 | -------------------------------------------------------------------------------- /console/src/store/services/announcements.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Announcement extends BaseModel { 4 | static modelName = 'Announcement' 5 | 6 | static instanceDefaults () { 7 | return { 8 | title: '', 9 | body: '', 10 | important: false, 11 | validFrom: null, 12 | validTo: null 13 | } 14 | } 15 | 16 | static setupInstance (data) { 17 | // Convert date strings into Date objects 18 | for (const prop of ['validFrom', 'validTo', 'updatedAt']) { 19 | if (data[prop]) { 20 | data[prop] = new Date(data[prop]) 21 | } 22 | } 23 | 24 | return data 25 | } 26 | } 27 | 28 | const servicePath = 'api/v1/announcements' 29 | const servicePlugin = makeServicePlugin({ 30 | Model: Announcement, 31 | service: feathersClient.service(servicePath), 32 | servicePath 33 | }) 34 | 35 | // Setup the client-side Feathers hooks. 36 | feathersClient.service(servicePath).hooks({ 37 | before: { 38 | all: [], 39 | find: [], 40 | get: [], 41 | create: [], 42 | update: [], 43 | patch: [], 44 | remove: [] 45 | }, 46 | after: { 47 | all: [], 48 | find: [], 49 | get: [], 50 | create: [], 51 | update: [], 52 | patch: [], 53 | remove: [] 54 | }, 55 | error: { 56 | all: [], 57 | find: [], 58 | get: [], 59 | create: [], 60 | update: [], 61 | patch: [], 62 | remove: [] 63 | } 64 | }) 65 | 66 | export default servicePlugin 67 | -------------------------------------------------------------------------------- /console/src/store/services/api-keys.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class ApiKey extends BaseModel { 4 | static modelName = 'ApiKey' 5 | 6 | static instanceDefaults () { 7 | return { 8 | name: '', 9 | displayId: null 10 | } 11 | } 12 | } 13 | 14 | const servicePath = 'api/v1/api-keys' 15 | const servicePlugin = makeServicePlugin({ 16 | Model: ApiKey, 17 | service: feathersClient.service(servicePath), 18 | servicePath, 19 | state: { 20 | createdApiKey: null 21 | }, 22 | mutations: { 23 | clearCreatedApiKey: function (state) { 24 | state.createdApiKey = null 25 | }, 26 | setCreatedApiKey: function (state, apiKey) { 27 | // Make a copy of the API key for one-time display 28 | state.createdApiKey = `${apiKey.id}:${apiKey.token}` 29 | } 30 | }, 31 | setupInstance: (data, { store }) => { 32 | // If this is a newly created API key that includes the token, copy the token and prevent it from being stored 33 | if (data.token) { 34 | store.commit('api-keys/setCreatedApiKey', data) 35 | delete data.token 36 | } 37 | return data 38 | } 39 | }) 40 | 41 | // Setup the client-side Feathers hooks. 42 | feathersClient.service(servicePath).hooks({ 43 | before: { 44 | all: [], 45 | find: [], 46 | get: [], 47 | create: [], 48 | update: [], 49 | patch: [], 50 | remove: [] 51 | }, 52 | after: { 53 | all: [], 54 | find: [], 55 | get: [], 56 | create: [], 57 | update: [], 58 | patch: [], 59 | remove: [] 60 | }, 61 | error: { 62 | all: [], 63 | find: [], 64 | get: [], 65 | create: [], 66 | update: [], 67 | patch: [], 68 | remove: [] 69 | } 70 | }) 71 | 72 | export default servicePlugin 73 | -------------------------------------------------------------------------------- /console/src/store/services/calendar-feeds.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class CalendarFeed extends BaseModel { 4 | static modelName = 'CalendarFeed' 5 | 6 | static instanceDefaults () { 7 | return { 8 | name: '', 9 | url: '', 10 | } 11 | } 12 | 13 | static setupInstance (data) { 14 | // Convert date strings into Date objects 15 | for (const prop of ['createdAt', 'updatedAt']) { 16 | if (data[prop]) { 17 | data[prop] = new Date(data[prop]) 18 | } 19 | } 20 | 21 | return data 22 | } 23 | } 24 | 25 | const servicePath = 'calendar-feeds' 26 | const servicePlugin = makeServicePlugin({ 27 | Model: CalendarFeed, 28 | service: feathersClient.service(servicePath), 29 | servicePath 30 | }) 31 | 32 | // Setup the client-side Feathers hooks. 33 | feathersClient.service(servicePath).hooks({ 34 | before: { 35 | all: [], 36 | find: [], 37 | get: [], 38 | create: [], 39 | update: [], 40 | patch: [], 41 | remove: [] 42 | }, 43 | after: { 44 | all: [], 45 | find: [], 46 | get: [], 47 | create: [], 48 | update: [], 49 | patch: [], 50 | remove: [] 51 | }, 52 | error: { 53 | all: [], 54 | find: [], 55 | get: [], 56 | create: [], 57 | update: [], 58 | patch: [], 59 | remove: [] 60 | } 61 | }) 62 | 63 | export default servicePlugin 64 | -------------------------------------------------------------------------------- /console/src/store/services/content-slots.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class ContentSlot extends BaseModel { 4 | static modelName = 'ContentSlot' 5 | 6 | static instanceDefaults () { 7 | return { 8 | component: 'Clock', 9 | columnStart: 1, 10 | columnEnd: 1, 11 | rowStart: 2, 12 | rowEnd: 2, 13 | options: {}, 14 | viewId: null 15 | } 16 | } 17 | } 18 | 19 | const servicePath = 'api/v1/content-slots' 20 | const servicePlugin = makeServicePlugin({ 21 | Model: ContentSlot, 22 | service: feathersClient.service(servicePath), 23 | servicePath 24 | }) 25 | 26 | // Setup the client-side Feathers hooks. 27 | feathersClient.service(servicePath).hooks({ 28 | before: { 29 | all: [], 30 | find: [], 31 | get: [], 32 | create: [], 33 | update: [], 34 | patch: [], 35 | remove: [] 36 | }, 37 | after: { 38 | all: [], 39 | find: [], 40 | get: [], 41 | create: [], 42 | update: [], 43 | patch: [], 44 | remove: [] 45 | }, 46 | error: { 47 | all: [], 48 | find: [], 49 | get: [], 50 | create: [], 51 | update: [], 52 | patch: [], 53 | remove: [] 54 | } 55 | }) 56 | 57 | export default servicePlugin 58 | -------------------------------------------------------------------------------- /console/src/store/services/displays.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Display extends BaseModel { 4 | static modelName = 'Display' 5 | 6 | static instanceDefaults () { 7 | return { 8 | name: '', 9 | active: false, 10 | description: '', 11 | type: 'monitor' 12 | } 13 | } 14 | 15 | static setupInstance (data, { models }) { 16 | // Add nested view objects to storage 17 | if (data.views && Array.isArray(data.views)) { 18 | data.views.forEach(view => new models.api.View(view)) 19 | } 20 | 21 | // Replace the nested views with a getter 22 | Object.defineProperty(data, 'views', { 23 | get: function () { 24 | const views = models.api.View.findInStore({ 25 | query: { 26 | displayId: data.id, 27 | $sort: { 28 | order: 1 29 | } 30 | } 31 | }) 32 | return views.data 33 | }, 34 | configurable: true, 35 | enumerable: true 36 | }) 37 | 38 | return data 39 | } 40 | } 41 | 42 | const servicePath = 'api/v1/displays' 43 | const servicePlugin = makeServicePlugin({ 44 | Model: Display, 45 | service: feathersClient.service(servicePath), 46 | servicePath 47 | }) 48 | 49 | // Setup the client-side Feathers hooks. 50 | feathersClient.service(servicePath).hooks({ 51 | before: { 52 | all: [], 53 | find: [], 54 | get: [], 55 | create: [], 56 | update: [], 57 | patch: [], 58 | remove: [] 59 | }, 60 | after: { 61 | all: [], 62 | find: [], 63 | get: [setOwnDisplayId], 64 | create: [], 65 | update: [], 66 | patch: [], 67 | remove: [] 68 | }, 69 | error: { 70 | all: [], 71 | find: [], 72 | get: [], 73 | create: [], 74 | update: [], 75 | patch: [], 76 | remove: [] 77 | } 78 | }) 79 | 80 | function setOwnDisplayId (context) { 81 | if (context.id === 'self') { 82 | console.log('Own Display ID is', context.result.id) 83 | context.service.FeathersVuexModel.store.commit('setOwnDisplayId', context.result.id) 84 | } 85 | } 86 | 87 | export default servicePlugin 88 | -------------------------------------------------------------------------------- /console/src/store/services/incidents.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Incident extends BaseModel { 4 | static modelName = 'Incident' 5 | 6 | static instanceDefaults () { 7 | return { 8 | time: new Date(), 9 | sender: '', 10 | ref: '', 11 | caller_name: '', 12 | caller_number: '', 13 | reason: '', 14 | keyword: '', 15 | description: '', 16 | status: 'Actual', 17 | category: 'Other' 18 | } 19 | } 20 | 21 | static setupInstance (data) { 22 | // Convert date strings into Date objects 23 | for (const prop of ['time']) { 24 | if (data[prop]) { 25 | data[prop] = new Date(data[prop]) 26 | } 27 | } 28 | 29 | return data 30 | } 31 | } 32 | 33 | const servicePath = 'api/v1/incidents' 34 | const servicePlugin = makeServicePlugin({ 35 | Model: Incident, 36 | service: feathersClient.service(servicePath), 37 | servicePath 38 | }) 39 | 40 | // Setup the client-side Feathers hooks. 41 | feathersClient.service(servicePath).hooks({ 42 | before: { 43 | all: [], 44 | find: [], 45 | get: [], 46 | create: [], 47 | update: [], 48 | patch: [], 49 | remove: [] 50 | }, 51 | after: { 52 | all: [], 53 | find: [], 54 | get: [], 55 | create: [], 56 | update: [], 57 | patch: [], 58 | remove: [] 59 | }, 60 | error: { 61 | all: [], 62 | find: [], 63 | get: [], 64 | create: [], 65 | update: [], 66 | patch: [], 67 | remove: [] 68 | } 69 | }) 70 | 71 | export default servicePlugin 72 | -------------------------------------------------------------------------------- /console/src/store/services/key-requests.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class KeyRequest extends BaseModel { 4 | static modelName = 'KeyRequest' 5 | 6 | static instanceDefaults () { 7 | return { 8 | requestId: '', 9 | name: '', 10 | granted: false, 11 | apiKey: '' 12 | } 13 | } 14 | } 15 | 16 | const servicePath = 'api/v1/key-requests' 17 | const servicePlugin = makeServicePlugin({ 18 | Model: KeyRequest, 19 | service: feathersClient.service(servicePath), 20 | servicePath 21 | }) 22 | 23 | // Setup the client-side Feathers hooks. 24 | feathersClient.service(servicePath).hooks({ 25 | before: { 26 | all: [], 27 | find: [], 28 | get: [], 29 | create: [], 30 | update: [], 31 | patch: [], 32 | remove: [] 33 | }, 34 | after: { 35 | all: [], 36 | find: [], 37 | get: [], 38 | create: [], 39 | update: [], 40 | patch: [], 41 | remove: [] 42 | }, 43 | error: { 44 | all: [], 45 | find: [], 46 | get: [], 47 | create: [], 48 | update: [], 49 | patch: [], 50 | remove: [] 51 | } 52 | }) 53 | 54 | export default servicePlugin 55 | -------------------------------------------------------------------------------- /console/src/store/services/locations.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Location extends BaseModel { 4 | static modelName = 'Location' 5 | 6 | static instanceDefaults () { 7 | return { 8 | rawText: '', 9 | latitude: undefined, 10 | longitude: undefined, 11 | name: '', 12 | street: '', 13 | number: '', 14 | detail: '', 15 | municipality: '', 16 | district: '', 17 | incidentId: undefined 18 | } 19 | } 20 | } 21 | 22 | const servicePath = 'api/v1/locations' 23 | const servicePlugin = makeServicePlugin({ 24 | Model: Location, 25 | service: feathersClient.service(servicePath), 26 | servicePath 27 | }) 28 | 29 | // Setup the client-side Feathers hooks. 30 | feathersClient.service(servicePath).hooks({ 31 | before: { 32 | all: [], 33 | find: [], 34 | get: [], 35 | create: [], 36 | update: [], 37 | patch: [], 38 | remove: [] 39 | }, 40 | after: { 41 | all: [], 42 | find: [], 43 | get: [], 44 | create: [], 45 | update: [], 46 | patch: [], 47 | remove: [] 48 | }, 49 | error: { 50 | all: [], 51 | find: [], 52 | get: [], 53 | create: [], 54 | update: [], 55 | patch: [], 56 | remove: [] 57 | } 58 | }) 59 | 60 | export default servicePlugin 61 | -------------------------------------------------------------------------------- /console/src/store/services/users.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class User extends BaseModel { 4 | static modelName = 'User' 5 | 6 | static instanceDefaults () { 7 | return { 8 | email: '', 9 | name: '' 10 | } 11 | } 12 | 13 | get displayName () { 14 | return this.name || this.email 15 | } 16 | } 17 | 18 | const servicePath = 'users' 19 | const servicePlugin = makeServicePlugin({ 20 | Model: User, 21 | service: feathersClient.service(servicePath), 22 | servicePath 23 | }) 24 | 25 | // Setup the client-side Feathers hooks. 26 | feathersClient.service(servicePath).hooks({ 27 | before: { 28 | all: [], 29 | find: [], 30 | get: [], 31 | create: [], 32 | update: [], 33 | patch: [], 34 | remove: [] 35 | }, 36 | after: { 37 | all: [], 38 | find: [], 39 | get: [], 40 | create: [], 41 | update: [], 42 | patch: [], 43 | remove: [] 44 | }, 45 | error: { 46 | all: [], 47 | find: [], 48 | get: [], 49 | create: [], 50 | update: [], 51 | patch: [], 52 | remove: [] 53 | } 54 | }) 55 | 56 | export default servicePlugin 57 | -------------------------------------------------------------------------------- /console/src/store/services/views.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class View extends BaseModel { 4 | static modelName = 'View' 5 | 6 | static instanceDefaults () { 7 | return { 8 | type: 'idle', 9 | order: 999, 10 | columns: 3, 11 | rows: 3, 12 | displayId: null, 13 | active: true, 14 | pinned: false, 15 | } 16 | } 17 | 18 | static setupInstance (data, { models }) { 19 | // Add nested content slot objects to storage 20 | if (data.contentSlots && Array.isArray(data.contentSlots)) { 21 | data.contentSlots.forEach(contentSlot => new models.api.ContentSlot(contentSlot)) 22 | } 23 | 24 | data.active = data.active === 1; 25 | data.pinned = data.pinned === 1; 26 | 27 | // Replace the nested content slots with a getter 28 | Object.defineProperty(data, 'contentSlots', { 29 | get: function () { 30 | const contentSlots = models.api.ContentSlot.findInStore({ 31 | query: { 32 | viewId: data.id 33 | } 34 | }) 35 | return contentSlots.data 36 | }, 37 | configurable: true, 38 | enumerable: true 39 | }) 40 | 41 | return data 42 | } 43 | } 44 | 45 | const servicePath = 'api/v1/views' 46 | const servicePlugin = makeServicePlugin({ 47 | Model: View, 48 | service: feathersClient.service(servicePath), 49 | servicePath 50 | }) 51 | 52 | // Setup the client-side Feathers hooks. 53 | feathersClient.service(servicePath).hooks({ 54 | before: { 55 | all: [], 56 | find: [], 57 | get: [], 58 | create: [], 59 | update: [], 60 | patch: [], 61 | remove: [] 62 | }, 63 | after: { 64 | all: [], 65 | find: [], 66 | get: [], 67 | create: [], 68 | update: [], 69 | patch: [], 70 | remove: [] 71 | }, 72 | error: { 73 | all: [], 74 | find: [], 75 | get: [], 76 | create: [], 77 | update: [], 78 | patch: [], 79 | remove: [] 80 | } 81 | }) 82 | 83 | export default servicePlugin 84 | -------------------------------------------------------------------------------- /console/src/store/socket.js: -------------------------------------------------------------------------------- 1 | // initial state 2 | const state = () => ({ 3 | connected: false 4 | }) 5 | 6 | const getters = {} 7 | 8 | const actions = {} 9 | 10 | const mutations = { 11 | setConnected (state, connected) { 12 | state.connected = connected === true 13 | } 14 | } 15 | 16 | export default { 17 | namespaced: true, 18 | state, 19 | getters, 20 | actions, 21 | mutations 22 | } 23 | 24 | export function createSocketPlugin (socket) { 25 | return store => { 26 | socket.on('connect', () => { 27 | store.commit('socket/setConnected', true) 28 | }) 29 | socket.on('disconnect', () => { 30 | store.commit('socket/setConnected', false) 31 | 32 | // Clear all key requests, because the server only stores them in memory and IDs could be reused after a restart 33 | store.commit('key-requests/clearAll') 34 | }) 35 | socket.on('connect_error', (err) => { 36 | console.error('Socket connect error', err) 37 | store.commit('socket/setConnected', false) 38 | }) 39 | socket.on('connect_timeout', (timeout) => { 40 | console.error('Socket connect timeout', timeout) 41 | }) 42 | socket.on('error', (err) => { 43 | console.error('Socket error', err) 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /console/src/store/store.auth.js: -------------------------------------------------------------------------------- 1 | import { makeAuthPlugin } from '@/feathers-client' 2 | 3 | function getExpiresAt (state) { 4 | const expiresAt = state.payload?.authentication?.payload?.exp 5 | if (!expiresAt) { 6 | return false 7 | } 8 | 9 | return expiresAt 10 | } 11 | 12 | export default makeAuthPlugin({ userService: 'users', entityIdField: 'id', getters: { expiresAt: getExpiresAt } }) 13 | -------------------------------------------------------------------------------- /console/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /console/src/views/Overview.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 20 | -------------------------------------------------------------------------------- /console/src/views/admin/Settings.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 59 | 60 | 63 | -------------------------------------------------------------------------------- /console/src/views/content/AnnouncementList.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 36 | 37 | 40 | -------------------------------------------------------------------------------- /console/src/views/content/CalendarFeedEditor.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 61 | 62 | 65 | -------------------------------------------------------------------------------- /console/src/views/displays/KeyRequestCard.vue: -------------------------------------------------------------------------------- 1 | 26 | 59 | -------------------------------------------------------------------------------- /console/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | proxy: { 4 | '^/socket\\.io/': { 5 | target: 'http://localhost:3031/', 6 | ws: true 7 | } 8 | } 9 | }, 10 | publicPath: process.env.NODE_ENV === 'production' ? '/console/' : '/' 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:vue/essential", 8 | "eslint:recommended" 9 | ], 10 | "parserOptions": { 11 | "parser": "@babel/eslint-parser" 12 | }, 13 | "reportUnusedDisableDirectives": true, 14 | "rules": { 15 | "vue/multi-word-component-names": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea/workspace.xml 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /frontend/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /frontend/.idea/frontend.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /frontend/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.2 2 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue.js Display Frontend 2 | 3 | This is the web application, that runs on each Display unit. 4 | The app maintains a [Socket.IO](https://socket.io/) connection to the [Display Server](../server) to get its configuration and data updates. 5 | 6 | ## Development 7 | In order to run a development version on your local system, you need a [Node.js](https://nodejs.org/) environment. 8 | Clone the repository and run `npm install` inside the project folder to install all the dependencies. 9 | 10 | Start the development server by running `npm run serve`, it will automatically restart when files have changed. 11 | Now you can access the server on http://localhost:8080 (may be a different port on your system, check the console output). 12 | If you run a development Display Server on http://localhost:3031, the requests are automatically proxied there. 13 | This allows for parallel development of the frontend and the backend. 14 | 15 | ### Libraries and frameworks 16 | This project uses the following libraries or frameworks, please refer to their documentation as well. 17 | - [Vue.js](https://vuejs.org/) 18 | - [Vuex](https://vuex.vuejs.org/) 19 | - [FeathersVuex](https://vuex.feathersjs.com/) 20 | - [Font Awesome](https://fontawesome.com/) 21 | 22 | ## Deployment 23 | Run `npm run build`, which compiles and minifies the app for production. 24 | You find the result of the build process in a folder called `dist`. 25 | This folder only contains HTML, CSS, and JS files, which means they can be hosted as static files. 26 | By default, the app expects to be accessible under the path `/display/`. 27 | You can change this behaviour by adapting the `publicPath` in [vue.config.js](vue.config.js). 28 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alarmdisplay/display-frontend", 3 | "version": "1.0.0-beta.5", 4 | "private": false, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "author": "Andreas Brain", 11 | "license": "AGPL-3.0-only", 12 | "dependencies": { 13 | "@feathersjs/feathers": "4.5.17", 14 | "@feathersjs/socketio-client": "4.5.18", 15 | "@fortawesome/fontawesome-svg-core": "6.7.2", 16 | "@fortawesome/free-solid-svg-icons": "6.7.2", 17 | "@fortawesome/vue-fontawesome": "2.0.10", 18 | "@vue/composition-api": "1.7.2", 19 | "axios": "1.8.4", 20 | "core-js": "3.41.0", 21 | "feathers-vuex": "3.16.0", 22 | "leaflet": "1.9.4", 23 | "moment": "2.30.1", 24 | "socket.io-client": "2.5.0", 25 | "vue": "2.6.14", 26 | "vue-moment": "4.1.0", 27 | "vue2-leaflet": "2.7.1", 28 | "vuex": "3.6.2" 29 | }, 30 | "devDependencies": { 31 | "@babel/eslint-parser": "7.27.1", 32 | "@vue/cli-plugin-babel": "5.0.8", 33 | "@vue/cli-plugin-eslint": "5.0.8", 34 | "@vue/cli-service": "5.0.8", 35 | "eslint": "8.57.1", 36 | "eslint-plugin-vue": "9.33.0", 37 | "vue-template-compiler": "2.6.14" 38 | }, 39 | "overrides": { 40 | "feathers-vuex": { 41 | "serialize-error": "11.0.3" 42 | } 43 | }, 44 | "browserslist": [ 45 | "> 1%", 46 | "last 2 versions" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alarmdisplay/display/833f4b4d27e8292df1f873d1142eba9cad80182d/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Alarmdisplay 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alarmdisplay/display/833f4b4d27e8292df1f873d1142eba9cad80182d/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/components/AlertBanner.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | 46 | -------------------------------------------------------------------------------- /frontend/src/components/AlertScreen.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | 26 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/Clock.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 41 | 42 | 61 | -------------------------------------------------------------------------------- /frontend/src/components/ConnectionBanner.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | 25 | 67 | -------------------------------------------------------------------------------- /frontend/src/components/DisplaySetup.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | 36 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/GridView.vue: -------------------------------------------------------------------------------- 1 | 6 | 38 | 39 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/GridViewComponent.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 35 | -------------------------------------------------------------------------------- /frontend/src/components/IdleScreen.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 49 | 50 | 57 | -------------------------------------------------------------------------------- /frontend/src/components/RemoteImage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 27 | 28 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/SplashScreen.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/src/components/announcements/Item.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | 32 | 60 | -------------------------------------------------------------------------------- /frontend/src/feathers-client.js: -------------------------------------------------------------------------------- 1 | import feathers from '@feathersjs/feathers' 2 | import socketio from '@feathersjs/socketio-client' 3 | import io from 'socket.io-client' 4 | import feathersVuex from 'feathers-vuex' 5 | 6 | let apiKey = localStorage.getItem('display-api-key'); 7 | if (!apiKey || apiKey === '') { 8 | // Make sure the header is not empty, so it gets sent and the authentication process kicks in 9 | apiKey = 'none' 10 | } 11 | let options = { 12 | transportOptions: { 13 | polling: { 14 | extraHeaders: { 15 | 'x-api-key': apiKey 16 | } 17 | } 18 | } 19 | }; 20 | 21 | const socket = io(options) 22 | 23 | const feathersClient = feathers() 24 | .configure(socketio(socket)) 25 | 26 | export default feathersClient 27 | 28 | // Setting up feathers-vuex 29 | const { makeServicePlugin, BaseModel, models, FeathersVuex } = feathersVuex( 30 | feathersClient, 31 | { 32 | serverAlias: 'api', 33 | idField: 'id', 34 | whitelist: ['$regex', '$options'] 35 | } 36 | ) 37 | 38 | export { makeServicePlugin, BaseModel, models, FeathersVuex } 39 | -------------------------------------------------------------------------------- /frontend/src/font-awesome.js: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core' 2 | 3 | import { 4 | faAnglesUp, 5 | faArrowUp, 6 | faBullhorn, 7 | faCalendar, 8 | faCircleQuestion, 9 | faClock, 10 | faCloud, 11 | faCloudBolt, 12 | faCloudMoon, 13 | faCloudRain, 14 | faCloudSun, 15 | faMoon, 16 | faSnowflake, 17 | faSpinner, 18 | faStopwatch, 19 | faSun, 20 | faTemperatureArrowDown, 21 | faTemperatureArrowUp, 22 | faTemperatureThreeQuarters, 23 | faWind, 24 | } from '@fortawesome/free-solid-svg-icons' 25 | 26 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 27 | 28 | library.add( 29 | faAnglesUp, 30 | faArrowUp, 31 | faBullhorn, 32 | faCalendar, 33 | faCircleQuestion, 34 | faClock, 35 | faCloud, 36 | faCloudBolt, 37 | faCloudMoon, 38 | faCloudRain, 39 | faCloudSun, 40 | faMoon, 41 | faSnowflake, 42 | faSpinner, 43 | faStopwatch, 44 | faSun, 45 | faTemperatureArrowDown, 46 | faTemperatureArrowUp, 47 | faTemperatureThreeQuarters, 48 | faWind, 49 | ); 50 | 51 | export default FontAwesomeIcon 52 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueCompositionApi from '@vue/composition-api' 3 | Vue.use(VueCompositionApi) 4 | 5 | import App from './App.vue' 6 | import store from './store' 7 | 8 | import moment from 'moment' 9 | import VueMoment from 'vue-moment'; 10 | 11 | // Load Font Awesome 12 | import FontAwesomeIcon from './font-awesome' 13 | Vue.component('font-awesome-icon', FontAwesomeIcon) 14 | 15 | require('moment/locale/de'); 16 | Vue.use(VueMoment, { moment }); 17 | 18 | import 'leaflet/dist/leaflet.css'; 19 | 20 | Vue.config.productionTip = false; 21 | 22 | new Vue({ 23 | render: h => h(App), 24 | store, 25 | data: { 26 | minutes: Math.floor(Date.now() / 60000), 27 | seconds: Math.floor(Date.now() / 1000) 28 | }, 29 | created: function () { 30 | this.$moment.locale('de'); 31 | }, 32 | mounted: function () { 33 | setTimeout(this.hideSplashScreen, 3000); 34 | setInterval(this.updateSeconds, 1000); 35 | }, 36 | methods: { 37 | updateSeconds: function () { 38 | this.seconds = Math.floor(Date.now() / 1000) 39 | }, 40 | hideSplashScreen: function () { 41 | this.$store.commit('setShowSplashScreen', false) 42 | } 43 | }, 44 | watch: { 45 | seconds(newValue) { 46 | this.minutes = Math.floor(newValue / 60); 47 | } 48 | } 49 | }).$mount('#app'); 50 | 51 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import feathersClient, { FeathersVuex } from '@/feathers-client' 4 | 5 | import announcements from './services/announcements' 6 | import displays from './services/displays' 7 | import calendarItems from './services/calendar-items' 8 | import contentSlots from './services/content-slots' 9 | import incidents from './services/incidents' 10 | import keyRequests from './services/key-requests' 11 | import locations from './services/locations' 12 | import settings from './services/settings' 13 | import views from "./services/views"; 14 | import socket, { createSocketPlugin } from "./socket"; 15 | 16 | Vue.use(Vuex) 17 | Vue.use(FeathersVuex) 18 | 19 | export default new Vuex.Store({ 20 | state: { 21 | ownDisplayId: undefined, 22 | showSplashScreen: true 23 | }, 24 | mutations: { 25 | setOwnDisplayId: (state, payload) => { 26 | state.ownDisplayId = payload 27 | }, 28 | setShowSplashScreen: (state, payload) => { 29 | state.showSplashScreen = payload 30 | } 31 | }, 32 | actions: { 33 | }, 34 | modules: { 35 | socket 36 | }, 37 | plugins: [ 38 | createSocketPlugin(feathersClient.io), 39 | announcements, 40 | calendarItems, 41 | contentSlots, 42 | displays, 43 | incidents, 44 | keyRequests, 45 | locations, 46 | settings, 47 | views 48 | ] 49 | }) 50 | -------------------------------------------------------------------------------- /frontend/src/store/services/announcements.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Announcement extends BaseModel { 4 | constructor(data, options) { 5 | super(data, options) 6 | } 7 | 8 | static modelName = 'Announcement' 9 | 10 | static instanceDefaults() { 11 | return { 12 | title: '', 13 | body: '', 14 | important: false, 15 | validFrom: null, 16 | validTo: null 17 | } 18 | } 19 | 20 | static setupInstance(data) { 21 | // Convert date strings into Date objects 22 | for (const prop of ['validFrom', 'validTo', 'updatedAt']) { 23 | if (data[prop]) { 24 | data[prop] = new Date(data[prop]) 25 | } 26 | } 27 | 28 | return data 29 | } 30 | } 31 | 32 | const servicePath = 'api/v1/announcements' 33 | const servicePlugin = makeServicePlugin({ 34 | Model: Announcement, 35 | service: feathersClient.service(servicePath), 36 | servicePath 37 | }) 38 | 39 | // Setup the client-side Feathers hooks. 40 | feathersClient.service(servicePath).hooks({ 41 | before: { 42 | all: [], 43 | find: [], 44 | get: [], 45 | create: [], 46 | update: [], 47 | patch: [], 48 | remove: [] 49 | }, 50 | after: { 51 | all: [], 52 | find: [], 53 | get: [], 54 | create: [], 55 | update: [], 56 | patch: [], 57 | remove: [] 58 | }, 59 | error: { 60 | all: [], 61 | find: [], 62 | get: [], 63 | create: [], 64 | update: [], 65 | patch: [], 66 | remove: [] 67 | } 68 | }) 69 | 70 | export default servicePlugin 71 | -------------------------------------------------------------------------------- /frontend/src/store/services/calendar-items.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class CalendarItem extends BaseModel { 4 | constructor(data, options) { 5 | super(data, options) 6 | } 7 | 8 | static modelName = 'CalendarItem' 9 | 10 | static instanceDefaults() { 11 | return { 12 | uid: '', 13 | summary: '', 14 | startDate: null, 15 | endDate: null, 16 | description: '', 17 | status: 'confirmed', 18 | allDayEvent: false, 19 | feedId: 0, 20 | } 21 | } 22 | 23 | static setupInstance(data) { 24 | // Convert date strings into Date objects 25 | for (const prop of ['startDate', 'endDate']) { 26 | if (data[prop]) { 27 | data[prop] = new Date(data[prop]) 28 | } 29 | } 30 | 31 | return data 32 | } 33 | } 34 | 35 | const servicePath = 'calendar-items' 36 | const servicePlugin = makeServicePlugin({ 37 | Model: CalendarItem, 38 | idField: 'uid', 39 | service: feathersClient.service(servicePath), 40 | servicePath 41 | }) 42 | 43 | // Setup the client-side Feathers hooks. 44 | feathersClient.service(servicePath).hooks({ 45 | before: { 46 | all: [], 47 | find: [], 48 | get: [], 49 | create: [], 50 | update: [], 51 | patch: [], 52 | remove: [] 53 | }, 54 | after: { 55 | all: [], 56 | find: [], 57 | get: [], 58 | create: [], 59 | update: [], 60 | patch: [], 61 | remove: [] 62 | }, 63 | error: { 64 | all: [], 65 | find: [], 66 | get: [], 67 | create: [], 68 | update: [], 69 | patch: [], 70 | remove: [] 71 | } 72 | }) 73 | 74 | export default servicePlugin 75 | -------------------------------------------------------------------------------- /frontend/src/store/services/content-slots.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class ContentSlot extends BaseModel { 4 | constructor(data, options) { 5 | super(data, options) 6 | } 7 | 8 | static modelName = 'ContentSlot' 9 | 10 | static instanceDefaults() { 11 | return { 12 | component: 'Clock', 13 | columnStart: 1, 14 | columnEnd: 1, 15 | rowStart: 2, 16 | rowEnd: 2, 17 | options: {} 18 | } 19 | } 20 | } 21 | 22 | const servicePath = 'api/v1/content-slots' 23 | const servicePlugin = makeServicePlugin({ 24 | Model: ContentSlot, 25 | service: feathersClient.service(servicePath), 26 | servicePath 27 | }) 28 | 29 | // Setup the client-side Feathers hooks. 30 | feathersClient.service(servicePath).hooks({ 31 | before: { 32 | all: [], 33 | find: [], 34 | get: [], 35 | create: [], 36 | update: [], 37 | patch: [], 38 | remove: [] 39 | }, 40 | after: { 41 | all: [], 42 | find: [], 43 | get: [], 44 | create: [], 45 | update: [], 46 | patch: [], 47 | remove: [] 48 | }, 49 | error: { 50 | all: [], 51 | find: [], 52 | get: [], 53 | create: [], 54 | update: [], 55 | patch: [], 56 | remove: [] 57 | } 58 | }) 59 | 60 | export default servicePlugin 61 | -------------------------------------------------------------------------------- /frontend/src/store/services/key-requests.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class KeyRequest extends BaseModel { 4 | static modelName = 'KeyRequest' 5 | 6 | static instanceDefaults () { 7 | return { 8 | requestId: '', 9 | name: '', 10 | granted: false, 11 | apiKey: '' 12 | } 13 | } 14 | } 15 | 16 | const servicePath = 'api/v1/key-requests' 17 | const servicePlugin = makeServicePlugin({ 18 | Model: KeyRequest, 19 | service: feathersClient.service(servicePath), 20 | servicePath 21 | }) 22 | 23 | // Setup the client-side Feathers hooks. 24 | feathersClient.service(servicePath).hooks({ 25 | before: { 26 | all: [], 27 | find: [], 28 | get: [], 29 | create: [], 30 | update: [], 31 | patch: [], 32 | remove: [] 33 | }, 34 | after: { 35 | all: [], 36 | find: [], 37 | get: [], 38 | create: [], 39 | update: [], 40 | patch: [], 41 | remove: [] 42 | }, 43 | error: { 44 | all: [], 45 | find: [], 46 | get: [], 47 | create: [], 48 | update: [], 49 | patch: [], 50 | remove: [] 51 | } 52 | }) 53 | 54 | export default servicePlugin 55 | -------------------------------------------------------------------------------- /frontend/src/store/services/locations.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class Location extends BaseModel { 4 | constructor(data, options) { 5 | super(data, options) 6 | } 7 | 8 | static modelName = 'Location' 9 | 10 | static instanceDefaults() { 11 | return { 12 | rawText: '', 13 | latitude: undefined, 14 | longitude: undefined, 15 | name: '', 16 | street: '', 17 | number: '', 18 | detail: '', 19 | municipality: '', 20 | district: '', 21 | incidentId: undefined 22 | } 23 | } 24 | } 25 | 26 | const servicePath = 'api/v1/locations' 27 | const servicePlugin = makeServicePlugin({ 28 | Model: Location, 29 | service: feathersClient.service(servicePath), 30 | servicePath 31 | }) 32 | 33 | // Setup the client-side Feathers hooks. 34 | feathersClient.service(servicePath).hooks({ 35 | before: { 36 | all: [], 37 | find: [], 38 | get: [], 39 | create: [], 40 | update: [], 41 | patch: [], 42 | remove: [] 43 | }, 44 | after: { 45 | all: [], 46 | find: [], 47 | get: [], 48 | create: [], 49 | update: [], 50 | patch: [], 51 | remove: [] 52 | }, 53 | error: { 54 | all: [], 55 | find: [], 56 | get: [], 57 | create: [], 58 | update: [], 59 | patch: [], 60 | remove: [] 61 | } 62 | }) 63 | 64 | export default servicePlugin 65 | -------------------------------------------------------------------------------- /frontend/src/store/services/settings.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { BaseModel, makeServicePlugin } from '../../feathers-client' 2 | 3 | class Setting extends BaseModel { 4 | static modelName = 'Setting' 5 | 6 | static instanceDefaults () { 7 | return { 8 | key: '', 9 | value: null 10 | } 11 | } 12 | } 13 | 14 | const servicePath = 'api/v1/settings' 15 | const servicePlugin = makeServicePlugin({ 16 | Model: Setting, 17 | idField: 'key', 18 | getters: { 19 | getIntegerValue: (state, getters) => (id, params) => { 20 | const value = getters.getValue(id, params) 21 | return Number.parseInt(value) 22 | }, 23 | getLeafletCoords: (state, getters) => (id, params) => { 24 | const value = getters.getValue(id, params) 25 | if (!value || !value.latitude || !value.longitude) { 26 | return null 27 | } 28 | 29 | return [value.latitude, value.longitude] 30 | }, 31 | getRegExp: (state, getters) => (id, params) => { 32 | const value = getters.getValue(id, params) 33 | try { 34 | return RegExp(value) 35 | } catch (e) { 36 | return null 37 | } 38 | }, 39 | getStringValue: (state, getters) => (id, params) => { 40 | const value = getters.getValue(id, params) 41 | return value ? String(value) : '' 42 | }, 43 | getValue: (state, getters) => (id, params) => { 44 | const setting = getters.get(id, params) 45 | return setting?.value 46 | } 47 | }, 48 | service: feathersClient.service(servicePath), 49 | servicePath 50 | }) 51 | 52 | // Setup the client-side Feathers hooks. 53 | feathersClient.service(servicePath).hooks({ 54 | before: { 55 | all: [], 56 | find: [], 57 | get: [], 58 | create: [], 59 | update: [], 60 | patch: [], 61 | remove: [] 62 | }, 63 | after: { 64 | all: [], 65 | find: [], 66 | get: [], 67 | create: [], 68 | update: [], 69 | patch: [], 70 | remove: [] 71 | }, 72 | error: { 73 | all: [], 74 | find: [], 75 | get: [], 76 | create: [], 77 | update: [], 78 | patch: [], 79 | remove: [] 80 | } 81 | }) 82 | 83 | export default servicePlugin 84 | -------------------------------------------------------------------------------- /frontend/src/store/services/views.js: -------------------------------------------------------------------------------- 1 | import feathersClient, { makeServicePlugin, BaseModel } from '../../feathers-client' 2 | 3 | class View extends BaseModel { 4 | constructor(data, options) { 5 | super(data, options) 6 | } 7 | 8 | static modelName = 'View' 9 | 10 | static instanceDefaults() { 11 | return { 12 | type: 'idle', 13 | order: 999, 14 | columns: 3, 15 | rows: 3, 16 | active: true, 17 | pinned: false, 18 | contentSlots: [] 19 | } 20 | } 21 | 22 | static setupInstance(data, { models }) { 23 | // Add nested content slot objects to storage 24 | if (data.contentSlots && Array.isArray(data.contentSlots)) { 25 | data.contentSlots.forEach(contentSlot => new models.api.ContentSlot(contentSlot)) 26 | } 27 | 28 | data.active = data.active === 1; 29 | data.pinned = data.pinned === 1; 30 | 31 | // Replace the nested content slots with a getter 32 | Object.defineProperty(data, 'contentSlots', { 33 | get: function () { 34 | const contentSlots = models.api.ContentSlot.findInStore({ 35 | query: { 36 | viewId: data.id 37 | } 38 | }) 39 | return contentSlots.data 40 | }, 41 | configurable: true, 42 | enumerable: true 43 | }) 44 | 45 | return data 46 | } 47 | } 48 | 49 | const servicePath = 'api/v1/views' 50 | const servicePlugin = makeServicePlugin({ 51 | Model: View, 52 | service: feathersClient.service(servicePath), 53 | servicePath 54 | }) 55 | 56 | // Setup the client-side Feathers hooks. 57 | feathersClient.service(servicePath).hooks({ 58 | before: { 59 | all: [], 60 | find: [], 61 | get: [], 62 | create: [], 63 | update: [], 64 | patch: [], 65 | remove: [] 66 | }, 67 | after: { 68 | all: [], 69 | find: [], 70 | get: [], 71 | create: [], 72 | update: [], 73 | patch: [], 74 | remove: [] 75 | }, 76 | error: { 77 | all: [], 78 | find: [], 79 | get: [], 80 | create: [], 81 | update: [], 82 | patch: [], 83 | remove: [] 84 | } 85 | }) 86 | 87 | export default servicePlugin 88 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | proxy: { 4 | '^/socket\\.io/': { 5 | target: 'http://localhost:3031/', 6 | ws: true 7 | } 8 | } 9 | }, 10 | publicPath: process.env.NODE_ENV === 'production' ? '/display/' : '/' 11 | }; 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices", 5 | "group:linters", 6 | "group:test", 7 | "npm:unpublishSafe", 8 | "schedule:automergeNonOfficeHours", 9 | ":approveMajorUpdates", 10 | ":automergeLinters", 11 | ":automergeTesters", 12 | ":maintainLockFilesWeekly", 13 | ":pinAllExceptPeerDependencies", 14 | ":semanticCommits", 15 | ":separateMultipleMajorReleases" 16 | ], 17 | "packageRules": [ 18 | { 19 | "matchPackageNames": [ 20 | "typescript" 21 | ], 22 | "groupName": "TypeScript", 23 | "separateMultipleMinor": true 24 | }, 25 | { 26 | "matchPackageNames": [ 27 | "vue", 28 | "vue-template-compiler" 29 | ], 30 | "allowedVersions": "< 2.7" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # The root directory of the project is one up 4 | cd "$(dirname $0)/.." 5 | PROJECT_DIR=$PWD 6 | echo "Project root is $PROJECT_DIR" 7 | 8 | if [[ -d build ]]; then 9 | echo "Removing old build folder..." 10 | rm -r build 11 | fi 12 | 13 | echo "Building server ..." 14 | cd "$PROJECT_DIR/server" 15 | npm ci 16 | npm run compile || exit 17 | cp package.json lib/ 18 | cp package-lock.json lib/ 19 | cp -r public lib/ 20 | 21 | mkdir lib/config 22 | cp config/default.json lib/config/ 23 | cp config/docker.json lib/config/ 24 | cp config/production.json lib/config/ 25 | 26 | mv lib "$PROJECT_DIR/build" 27 | 28 | echo "Building console ..." 29 | cd "$PROJECT_DIR/console" 30 | npm ci 31 | npm run build || exit 32 | mv dist "$PROJECT_DIR/build/ext-console" 33 | 34 | echo "Building frontend ..." 35 | cd "$PROJECT_DIR/frontend" 36 | npm ci 37 | npm run build || exit 38 | mv dist "$PROJECT_DIR/build/ext-display" 39 | 40 | cd "$PROJECT_DIR" 41 | cp LICENSE build/ 42 | -------------------------------------------------------------------------------- /server/.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | plugins: 3 | eslint: 4 | enabled: true 5 | channel: "eslint-6" 6 | exclude_patterns: 7 | - ".idea/" 8 | - ".*.json" 9 | - ".*.yml" 10 | - ".*ignore" 11 | - "node_modules/" 12 | - "__tests__/" 13 | - "*.js" 14 | - "*.json" 15 | -------------------------------------------------------------------------------- /server/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=1 2 | DB_HOST=localhost 3 | DB_USER=ad_display 4 | DB_PASSWORD= 5 | DB_NAME=ad_display 6 | #PORT=3000 7 | -------------------------------------------------------------------------------- /server/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 14 | 15 | 17 | 18 | 32 | 33 | -------------------------------------------------------------------------------- /server/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /server/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /server/.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /server/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/.idea/server.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /server/.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.2 2 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Display Server 2 | 3 | The backend provides a REST API as well as WebSocket connections to notify clients about updates. 4 | In production, it also serves the [Console](../console) and [Display](../frontend) frontends. 5 | 6 | ## Development 7 | In order to run a development version on your local system, you need a [Node.js](https://nodejs.org/) environment, and a MariaDB instance. 8 | - Clone the repository and run `npm install` in this folder to install all the dependencies. 9 | - In the `config/` folder, copy the file `development.json` to `local-development.json`. 10 | - At least set the `mysql` property of `local-development.json` to set up the database connection (e.g. `mysql://user:password@localhost:3306/database`). 11 | 12 | Start the development server by running `npm run dev`, it will automatically restart when files have changed. 13 | Now you can access the server on http://localhost:3031. 14 | 15 | Before the first run, and whenever you work on database migration scripts, you'll have to run `npm run start` once. 16 | Unfortunately, running `npm run dev` does not take the migration scripts into account. 17 | 18 | ### Libraries and frameworks 19 | This project uses the following libraries or frameworks, please refer to their documentation as well. 20 | - [FeathersJS](https://feathersjs.com/) 21 | 22 | ## Deployment 23 | At the moment, this project is not ready for deployment outside of a development environment. 24 | -------------------------------------------------------------------------------- /server/config/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "dbConfig": { 3 | "dialect": "mysql", 4 | "connection": "MYSQL_URI" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 3031, 4 | "public": "../public/", 5 | "paginate": { 6 | "default": 10, 7 | "max": 50 8 | }, 9 | "authentication": { 10 | "entity": "user", 11 | "service": "users", 12 | "secret": "L6dLJEWc23hFtMrxXJJjxgbD37o=", 13 | "authStrategies": [ 14 | "jwt", 15 | "local" 16 | ], 17 | "jwtOptions": { 18 | "header": { 19 | "typ": "access" 20 | }, 21 | "issuer": "Alarmdisplay Display", 22 | "algorithm": "HS256", 23 | "expiresIn": "1d" 24 | }, 25 | "local": { 26 | "usernameField": "email", 27 | "passwordField": "password" 28 | } 29 | }, 30 | "dbConfig": { 31 | "dialect": "mysql", 32 | "connection": "MYSQL_URI" 33 | }, 34 | "logging": { 35 | "level": "info" 36 | }, 37 | "dbMaxRetries": 5, 38 | "hub_host": "", 39 | "hub_api_key": "" 40 | } 41 | -------------------------------------------------------------------------------- /server/config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "logging": { 3 | "level": "debug" 4 | }, 5 | "dbConfig": { 6 | "dialect": "sqlite3", 7 | "connection": { 8 | "filename": "src/db.sqlite" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/config/docker.json: -------------------------------------------------------------------------------- 1 | { 2 | "dbConfig": { 3 | "dialect": "mysql", 4 | "connection": "MYSQL_URI" 5 | }, 6 | "hub_host": "HUB_HOST", 7 | "hub_api_key": "HUB_API_KEY", 8 | "logging": { 9 | "level": "LOG_LEVEL" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /server/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "logging": { 3 | "level": "error" 4 | }, 5 | "port": 8998, 6 | "dbConfig": { 7 | "dialect": "sqlite", 8 | "connection": "sqlite::memory:" 9 | }, 10 | "dbMaxRetries": 10 11 | } 12 | -------------------------------------------------------------------------------- /server/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import eslint from '@eslint/js'; 3 | import typescriptEslint from 'typescript-eslint'; 4 | 5 | export default typescriptEslint.config( 6 | eslint.configs.recommended, 7 | ...typescriptEslint.configs.recommended, 8 | { 9 | files: [ '**/*.js', '**/*.ts' ], 10 | ignores: ['**/.idea/', '**/jest.config.js'], 11 | }, 12 | { 13 | languageOptions: { 14 | globals: { 15 | ...globals.node, 16 | }, 17 | ecmaVersion: 2018, 18 | sourceType: 'module', 19 | }, 20 | rules: { 21 | indent: ['error', 2], 22 | 'linebreak-style': ['error', 'unix'], 23 | quotes: ['error', 'single'], 24 | semi: ['error', 'always'], 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/no-empty-interface': 'off', 27 | }, 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /server/jest.config.js: -------------------------------------------------------------------------------- 1 | const { dirname } = require('path'); 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | coverageDirectory: "coverage", 7 | coverageReporters: [["lcovonly", {"projectRoot": dirname(__dirname)}], ["text", {"skipFull": true}]], 8 | testSequencer: "./test/testSequencer.js", 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | diagnostics: false, 14 | }, 15 | ], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /server/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alarmdisplay/display/833f4b4d27e8292df1f873d1142eba9cad80182d/server/public/favicon.ico -------------------------------------------------------------------------------- /server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Alarmdisplay 7 | 8 | 9 | 10 |

Alarmdisplay

11 |

Es gibt zwei Anwendungen, die von diesem Server bereitgestellt werden:

12 |

Display

13 |

Alarmanzeige

14 | Zum Display 15 | 16 |

Console

17 |

Verwaltung von Displays und deren Inhalten

18 | Zur Console 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Open Sans", sans-serif; 3 | } 4 | -------------------------------------------------------------------------------- /server/src/app.hooks.ts: -------------------------------------------------------------------------------- 1 | // Application hooks that run for every service 2 | // Don't remove this comment. It's needed to format import lines nicely. 3 | 4 | export default { 5 | before: { 6 | all: [], 7 | find: [], 8 | get: [], 9 | create: [], 10 | update: [], 11 | patch: [], 12 | remove: [] 13 | }, 14 | 15 | after: { 16 | all: [], 17 | find: [], 18 | get: [], 19 | create: [], 20 | update: [], 21 | patch: [], 22 | remove: [] 23 | }, 24 | 25 | error: { 26 | all: [], 27 | find: [], 28 | get: [], 29 | create: [], 30 | update: [], 31 | patch: [], 32 | remove: [] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /server/src/authentication.ts: -------------------------------------------------------------------------------- 1 | import { ServiceAddons } from '@feathersjs/feathers'; 2 | import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication'; 3 | import { LocalStrategy } from '@feathersjs/authentication-local'; 4 | import { ApiKeyStrategy } from './auth-strategies/api-key.strategy'; 5 | 6 | import { Application } from './declarations'; 7 | 8 | declare module './declarations' { 9 | interface ServiceTypes { 10 | 'authentication': AuthenticationService & ServiceAddons; 11 | } 12 | } 13 | 14 | export default function(app: Application): void { 15 | const authentication = new AuthenticationService(app); 16 | 17 | authentication.register('jwt', new JWTStrategy()); 18 | authentication.register('local', new LocalStrategy()); 19 | authentication.register('api-key', new ApiKeyStrategy()); 20 | 21 | app.use('/authentication', authentication); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | import { Application as ExpressFeathers } from '@feathersjs/express'; 2 | 3 | // A mapping of service names to types. Will be extended in service files. 4 | export interface ServiceTypes {} // eslint-disable-line @typescript-eslint/no-empty-object-type 5 | // The application instance type that will be used everywhere else 6 | export type Application = ExpressFeathers; 7 | -------------------------------------------------------------------------------- /server/src/hooks/allowApiKey.ts: -------------------------------------------------------------------------------- 1 | import {HookContext} from '@feathersjs/feathers'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 4 | export function allowApiKey(options = {}) { 5 | return async (context: HookContext): Promise => { 6 | const { params } = context; 7 | 8 | // Stop, if it is an internal call or another authentication has been performed already 9 | if (!params.provider || params.authentication) { 10 | return context; 11 | } 12 | 13 | // Extract the API key from the request 14 | if(params.headers && params.headers['x-api-key']) { 15 | context.params = { 16 | ...params, 17 | authentication: { 18 | strategy: 'api-key', 19 | 'api-key': params.headers['x-api-key'] 20 | } 21 | }; 22 | } 23 | 24 | return context; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /server/src/hooks/unserializeJson.ts: -------------------------------------------------------------------------------- 1 | import { HookContext } from '@feathersjs/feathers'; 2 | import { getItems, replaceItems } from 'feathers-hooks-common'; 3 | 4 | export function unserializeJson (property: string) { 5 | return (context: HookContext): HookContext => { 6 | const items = getItems(context); 7 | if (Array.isArray(items)) { 8 | items.forEach(item => { 9 | if (typeof item[property] === 'string') { 10 | item[property] = item[property].length === 0 ? null : JSON.parse(item[property]); 11 | } 12 | }); 13 | } else { 14 | if (typeof items[property] === 'string') { 15 | items[property] = items[property].length === 0 ? null : JSON.parse(items[property]); 16 | } 17 | } 18 | 19 | replaceItems(context, items); 20 | return context; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import logger from './logger'; 3 | 4 | const port = app.get('port'); 5 | const server = app.listen(port); 6 | 7 | process.on('unhandledRejection', (reason, p) => 8 | logger.error('Unhandled Rejection at: Promise ', p, reason) 9 | ); 10 | 11 | server.on('listening', () => 12 | logger.info('Display Backend started on http://%s:%d', app.get('host'), port) 13 | ); 14 | -------------------------------------------------------------------------------- /server/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { getLogger as getLog4JsLogger } from 'log4js'; 2 | 3 | const logger = getLogger(); 4 | 5 | // Set fallback level, should be overridden by config 6 | logger.level = 'info'; 7 | 8 | export default logger; 9 | 10 | export function getLogger(category?: string) { 11 | return getLog4JsLogger(category); 12 | } 13 | -------------------------------------------------------------------------------- /server/src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../declarations'; 2 | // Don't remove this comment. It's needed to format import lines nicely. 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | export default function (app: Application): void { 6 | } 7 | -------------------------------------------------------------------------------- /server/src/migrations/00000_create-users.ts: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.createTable('users', { 6 | id: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | autoIncrement: true, 10 | primaryKey: true 11 | }, 12 | email: { 13 | type: Sequelize.STRING, 14 | allowNull: false, 15 | unique: true 16 | }, 17 | name: { 18 | type: Sequelize.STRING, 19 | allowNull: false, 20 | defaultValue: '' 21 | }, 22 | password: { 23 | type: Sequelize.STRING, 24 | allowNull: false 25 | }, 26 | createdAt: { 27 | type: Sequelize.DATE, 28 | allowNull: false 29 | }, 30 | updatedAt: { 31 | type: Sequelize.DATE, 32 | allowNull: false 33 | } 34 | }); 35 | }; 36 | 37 | export const down: Migration = async ({context: {query}}) => { 38 | await query.dropTable('users'); 39 | }; 40 | -------------------------------------------------------------------------------- /server/src/migrations/00001_create-displays.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.createTable('displays', { 6 | id: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | autoIncrement: true, 10 | primaryKey: true 11 | }, 12 | name: { 13 | type: DataTypes.STRING, 14 | allowNull: false 15 | }, 16 | active: { 17 | type: DataTypes.BOOLEAN, 18 | allowNull: false, 19 | defaultValue: false 20 | }, 21 | description: { 22 | type: DataTypes.STRING, 23 | allowNull: false, 24 | defaultValue: '' 25 | }, 26 | createdAt: { 27 | type: Sequelize.DATE, 28 | allowNull: false 29 | }, 30 | updatedAt: { 31 | type: Sequelize.DATE, 32 | allowNull: false 33 | } 34 | }); 35 | }; 36 | export const down: Migration = async ({context: {query}}) => { 37 | await query.dropTable('displays'); 38 | }; 39 | -------------------------------------------------------------------------------- /server/src/migrations/00002_create-views.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'views'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | id: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | autoIncrement: true, 20 | primaryKey: true 21 | }, 22 | type: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | order: { 27 | type: DataTypes.MEDIUMINT, 28 | allowNull: false 29 | }, 30 | columns: { 31 | type: DataTypes.TINYINT, 32 | allowNull: false 33 | }, 34 | rows: { 35 | type: DataTypes.TINYINT, 36 | allowNull: false 37 | }, 38 | displayId: { 39 | type: Sequelize.INTEGER, 40 | allowNull: false 41 | }, 42 | createdAt: { 43 | type: Sequelize.DATE, 44 | allowNull: false 45 | }, 46 | updatedAt: { 47 | type: Sequelize.DATE, 48 | allowNull: false 49 | } 50 | }); 51 | 52 | await query.addIndex(tableName, { 53 | name: 'displayId', 54 | fields: ['displayId'] 55 | }); 56 | 57 | await query.addConstraint(tableName, { 58 | name: `${tableName}_ibfk_1`, 59 | type: 'foreign key', 60 | fields: ['displayId'], 61 | references: { table: 'displays', field: 'id' }, 62 | onDelete: 'CASCADE', 63 | onUpdate: 'CASCADE' 64 | }); 65 | }; 66 | export const down: Migration = async ({context: {query}}) => { 67 | await query.dropTable('views'); 68 | }; 69 | -------------------------------------------------------------------------------- /server/src/migrations/00003_create-content-slots.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'content_slots'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | id: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | autoIncrement: true, 20 | primaryKey: true 21 | }, 22 | component: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | columnStart: { 27 | type: DataTypes.TINYINT, 28 | allowNull: false 29 | }, 30 | columnEnd: { 31 | type: DataTypes.TINYINT, 32 | allowNull: false 33 | }, 34 | rowStart: { 35 | type: DataTypes.TINYINT, 36 | allowNull: false 37 | }, 38 | rowEnd: { 39 | type: DataTypes.TINYINT, 40 | allowNull: false 41 | }, 42 | createdAt: { 43 | type: Sequelize.DATE, 44 | allowNull: false 45 | }, 46 | updatedAt: { 47 | type: Sequelize.DATE, 48 | allowNull: false 49 | }, 50 | viewId: { 51 | type: Sequelize.INTEGER, 52 | allowNull: false 53 | } 54 | }); 55 | 56 | await query.addIndex(tableName, { 57 | name: 'viewId', 58 | fields: ['viewId'] 59 | }); 60 | 61 | await query.addConstraint(tableName, { 62 | name: `${tableName}_ibfk_1`, 63 | type: 'foreign key', 64 | fields: ['viewId'], 65 | references: { table: 'views', field: 'id' }, 66 | onDelete: 'CASCADE', 67 | onUpdate: 'CASCADE' 68 | }); 69 | }; 70 | export const down: Migration = async ({context: {query}}) => { 71 | await query.dropTable('content_slots'); 72 | }; 73 | -------------------------------------------------------------------------------- /server/src/migrations/00004_create-content-slot-options.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'content_slot_options'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | id: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | autoIncrement: true, 20 | primaryKey: true 21 | }, 22 | key: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | value: { 27 | type: DataTypes.STRING, 28 | allowNull: false 29 | }, 30 | createdAt: { 31 | type: Sequelize.DATE, 32 | allowNull: false 33 | }, 34 | updatedAt: { 35 | type: Sequelize.DATE, 36 | allowNull: false 37 | }, 38 | contentSlotId: { 39 | type: Sequelize.INTEGER, 40 | allowNull: false 41 | } 42 | }); 43 | 44 | await query.addIndex(tableName, { 45 | name: 'contentSlotId', 46 | fields: ['contentSlotId'] 47 | }); 48 | 49 | await query.addConstraint(tableName, { 50 | name: `${tableName}_ibfk_1`, 51 | type: 'foreign key', 52 | fields: ['contentSlotId'], 53 | references: { table: 'content_slots', field: 'id' }, 54 | onDelete: 'CASCADE', 55 | onUpdate: 'CASCADE' 56 | }); 57 | }; 58 | export const down: Migration = async ({context: {query}}) => { 59 | await query.dropTable('content_slot_options'); 60 | }; 61 | -------------------------------------------------------------------------------- /server/src/migrations/00005_create-api-keys.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'api_keys'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | id: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | autoIncrement: true, 20 | primaryKey: true 21 | }, 22 | name: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | tokenHash: { 27 | type: DataTypes.STRING, 28 | allowNull: false, 29 | unique: true 30 | }, 31 | createdAt: { 32 | type: Sequelize.DATE, 33 | allowNull: false 34 | }, 35 | updatedAt: { 36 | type: Sequelize.DATE, 37 | allowNull: false 38 | }, 39 | displayId: { 40 | type: Sequelize.INTEGER, 41 | allowNull: true 42 | } 43 | }); 44 | 45 | await query.addIndex(tableName, { 46 | name: 'displayId', 47 | fields: ['displayId'] 48 | }); 49 | 50 | await query.addConstraint(tableName, { 51 | name: `${tableName}_ibfk_1`, 52 | type: 'foreign key', 53 | fields: ['displayId'], 54 | references: { table: 'displays', field: 'id' }, 55 | onDelete: 'CASCADE', 56 | onUpdate: 'CASCADE' 57 | }); 58 | }; 59 | export const down: Migration = async ({context: {query}}) => { 60 | await query.dropTable('api_keys'); 61 | }; 62 | -------------------------------------------------------------------------------- /server/src/migrations/00006_create-incidents.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.createTable('incidents', { 6 | id: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | autoIncrement: true, 10 | primaryKey: true 11 | }, 12 | time: { 13 | type: DataTypes.DATE, 14 | allowNull: false, 15 | defaultValue: DataTypes.NOW 16 | }, 17 | sender: { 18 | type: DataTypes.STRING, 19 | allowNull: false, 20 | defaultValue: '' 21 | }, 22 | ref: { 23 | type: DataTypes.STRING, 24 | allowNull: false, 25 | defaultValue: '' 26 | }, 27 | caller_name: { 28 | type: DataTypes.STRING, 29 | allowNull: false, 30 | defaultValue: '' 31 | }, 32 | caller_number: { 33 | type: DataTypes.STRING, 34 | allowNull: false, 35 | defaultValue: '' 36 | }, 37 | reason: { 38 | type: DataTypes.STRING, 39 | allowNull: false, 40 | defaultValue: '' 41 | }, 42 | keyword: { 43 | type: DataTypes.STRING, 44 | allowNull: false, 45 | defaultValue: '' 46 | }, 47 | description: { 48 | type: DataTypes.STRING, 49 | allowNull: false, 50 | defaultValue: '' 51 | }, 52 | status: { 53 | type: DataTypes.ENUM, 54 | values: ['Actual', 'Exercise', 'Test'], 55 | allowNull: false, 56 | defaultValue: 'Actual' 57 | }, 58 | category: { 59 | type: DataTypes.ENUM, 60 | values: ['Geo', 'Met', 'Safety', 'Security', 'Rescue', 'Fire', 'Health', 'Env', 'Transport', 'Infra', 'CBRNE', 'Other'], 61 | allowNull: false, 62 | defaultValue: 'Other' 63 | }, 64 | hubIncidentId: { 65 | type: DataTypes.INTEGER, 66 | allowNull: true 67 | }, 68 | createdAt: { 69 | type: Sequelize.DATE, 70 | allowNull: false 71 | }, 72 | updatedAt: { 73 | type: Sequelize.DATE, 74 | allowNull: false 75 | } 76 | }); 77 | }; 78 | export const down: Migration = async ({context: {query}}) => { 79 | await query.dropTable('incidents'); 80 | }; 81 | -------------------------------------------------------------------------------- /server/src/migrations/00008_create-announcements.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.createTable('announcements', { 6 | id: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | autoIncrement: true, 10 | primaryKey: true 11 | }, 12 | title: { 13 | type: DataTypes.STRING, 14 | allowNull: false 15 | }, 16 | body: { 17 | type: DataTypes.STRING, 18 | allowNull: true 19 | }, 20 | important: { 21 | type: DataTypes.BOOLEAN, 22 | allowNull: false, 23 | defaultValue: false 24 | }, 25 | validFrom: { 26 | type: DataTypes.DATE, 27 | allowNull: true 28 | }, 29 | validTo: { 30 | type: DataTypes.DATE, 31 | allowNull: true 32 | }, 33 | createdAt: { 34 | type: Sequelize.DATE, 35 | allowNull: false 36 | }, 37 | updatedAt: { 38 | type: Sequelize.DATE, 39 | allowNull: false 40 | } 41 | }); 42 | }; 43 | export const down: Migration = async ({context: {query}}) => { 44 | await query.dropTable('announcements'); 45 | }; 46 | -------------------------------------------------------------------------------- /server/src/migrations/00009_fix-locations-constraint.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '../sequelize'; 2 | 3 | export const up: Migration = async ({context: {query}}) => { 4 | const tableName = 'locations'; 5 | 6 | // Remove the incorrect constraint 7 | await query.removeConstraint(tableName, `${tableName}_ibfk_1`); 8 | 9 | // Add it again with the correct parameters 10 | await query.addConstraint(tableName, { 11 | name: `${tableName}_ibfk_1`, 12 | type: 'foreign key', 13 | fields: ['incidentId'], 14 | references: { table: 'incidents', field: 'id' }, 15 | onDelete: 'CASCADE', 16 | onUpdate: 'CASCADE' 17 | }); 18 | }; 19 | export const down: Migration = async ({context: {query}}) => { 20 | const tableName = 'locations'; 21 | 22 | // Remove the correct constraint 23 | await query.removeConstraint(tableName, `${tableName}_ibfk_1`); 24 | 25 | // Add it again with the old, but incorrect parameters 26 | await query.addConstraint(tableName, { 27 | name: `${tableName}_ibfk_1`, 28 | type: 'foreign key', 29 | fields: ['incidentId'], 30 | references: { table: 'incidents', field: 'id' }, 31 | onDelete: 'SET NULL', 32 | onUpdate: 'CASCADE' 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /server/src/migrations/00010_extend_incident_description.ts: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.changeColumn('incidents', 'description', { 6 | type: Sequelize.TEXT, 7 | allowNull: false, 8 | defaultValue: '' 9 | }); 10 | }; 11 | export const down: Migration = async ({context: {query}}) => { 12 | await query.changeColumn('incidents', 'description', { 13 | type: Sequelize.STRING, 14 | allowNull: false, 15 | defaultValue: '' 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /server/src/migrations/00011_create-settings.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'settings'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | key: { 17 | type: DataTypes.STRING, 18 | allowNull: false, 19 | primaryKey: true 20 | }, 21 | value: { 22 | type: DataTypes.JSON, 23 | allowNull: true 24 | }, 25 | createdAt: { 26 | type: Sequelize.DATE, 27 | allowNull: false 28 | }, 29 | updatedAt: { 30 | type: Sequelize.DATE, 31 | allowNull: false 32 | } 33 | }); 34 | }; 35 | export const down: Migration = async ({context: {query}}) => { 36 | await query.dropTable('settings'); 37 | }; 38 | -------------------------------------------------------------------------------- /server/src/migrations/00013_add-display-type.ts: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.addColumn('displays', 'type', { 6 | type: Sequelize.STRING(20), 7 | allowNull: false, 8 | defaultValue: 'monitor' 9 | }); 10 | }; 11 | export const down: Migration = async ({context: {query}}) => { 12 | await query.removeColumn('displays', 'type'); 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/migrations/00014_split-locality.ts: -------------------------------------------------------------------------------- 1 | import {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'locations'; 6 | 7 | await query.renameColumn(tableName, 'locality', 'municipality'); 8 | await query.addColumn(tableName, 'district', { 9 | type: DataTypes.STRING, 10 | allowNull: false, 11 | defaultValue: '' 12 | }); 13 | }; 14 | export const down: Migration = async ({context: {query}}) => { 15 | const tableName = 'locations'; 16 | await query.removeColumn(tableName, 'district'); 17 | await query.renameColumn(tableName, 'municipality', 'locality'); 18 | }; 19 | -------------------------------------------------------------------------------- /server/src/migrations/00015_create-calendar-feeds.ts: -------------------------------------------------------------------------------- 1 | import Sequelize, {DataTypes} from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | const tableName = 'calendar_feeds'; 6 | 7 | try { 8 | await query.describeTable(tableName); 9 | // Exit early if the table exists 10 | return; 11 | } catch { 12 | // The table does not exist, so we just continue 13 | } 14 | 15 | await query.createTable(tableName, { 16 | id: { 17 | type: Sequelize.INTEGER, 18 | allowNull: false, 19 | autoIncrement: true, 20 | primaryKey: true 21 | }, 22 | name: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | url: { 27 | type: DataTypes.STRING, 28 | allowNull: false 29 | }, 30 | createdAt: { 31 | type: Sequelize.DATE, 32 | allowNull: false 33 | }, 34 | updatedAt: { 35 | type: Sequelize.DATE, 36 | allowNull: false 37 | } 38 | }); 39 | 40 | await query.addConstraint(tableName, { 41 | name: 'name', 42 | type: 'unique', 43 | fields: ['name'] 44 | }); 45 | }; 46 | export const down: Migration = async ({context: {query}}) => { 47 | await query.dropTable('calendar_feeds'); 48 | }; 49 | -------------------------------------------------------------------------------- /server/src/migrations/00016_add-view-state.ts: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.addColumn('views', 'active', { 6 | type: Sequelize.BOOLEAN, 7 | allowNull: false, 8 | defaultValue: true 9 | }); 10 | }; 11 | export const down: Migration = async ({context: {query}}) => { 12 | await query.removeColumn('views', 'active'); 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/migrations/00017_add-view-pinned.ts: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { Migration } from '../sequelize'; 3 | 4 | export const up: Migration = async ({context: {query}}) => { 5 | await query.addColumn('views', 'pinned', { 6 | type: Sequelize.BOOLEAN, 7 | allowNull: false, 8 | defaultValue: false 9 | }); 10 | }; 11 | export const down: Migration = async ({context: {query}}) => { 12 | await query.removeColumn('views', 'pinned'); 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/models/announcements.model.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, Sequelize } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const Announcement = sequelizeClient.define('announcement', { 8 | title: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | body: { 13 | type: DataTypes.STRING, 14 | allowNull: true 15 | }, 16 | important: { 17 | type: DataTypes.BOOLEAN, 18 | allowNull: false, 19 | defaultValue: false 20 | }, 21 | validFrom: { 22 | type: DataTypes.DATE, 23 | allowNull: true 24 | }, 25 | validTo: { 26 | type: DataTypes.DATE, 27 | allowNull: true 28 | } 29 | }, { 30 | hooks: { 31 | beforeCount(options: any): HookReturn { 32 | options.raw = true; 33 | } 34 | }, 35 | tableName: 'announcements' 36 | }); 37 | return Announcement; 38 | } 39 | -------------------------------------------------------------------------------- /server/src/models/api-keys.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const ApiKey = sequelizeClient.define('api_key', { 8 | name: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | tokenHash: { 13 | type: DataTypes.STRING, 14 | allowNull: false, 15 | unique: true 16 | } 17 | }, { 18 | hooks: { 19 | beforeCount(options: any): HookReturn { 20 | options.raw = true; 21 | } 22 | }, 23 | tableName: 'api_keys' 24 | }); 25 | 26 | (ApiKey as any).associate = function (models: any): void { 27 | models.api_key.belongsTo(models.display); 28 | }; 29 | 30 | return ApiKey; 31 | } 32 | -------------------------------------------------------------------------------- /server/src/models/calendar-feeds.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { Application } from '../declarations'; 3 | import { HookReturn } from 'sequelize/types/hooks'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const CalendarFeed = sequelizeClient.define('calendar_feed', { 8 | name: { 9 | type: DataTypes.STRING, 10 | allowNull: false, 11 | unique: true, 12 | }, 13 | url: { 14 | type: DataTypes.STRING, 15 | allowNull: false, 16 | validate: { 17 | isUrl: true 18 | }, 19 | }, 20 | }, { 21 | hooks: { 22 | beforeCount(options: any): HookReturn { 23 | options.raw = true; 24 | } 25 | }, 26 | tableName: 'calendar_feeds' 27 | }); 28 | 29 | return CalendarFeed; 30 | } 31 | -------------------------------------------------------------------------------- /server/src/models/content-slots.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const ContentSlot = sequelizeClient.define('content_slot', { 8 | component: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | columnStart: { 13 | type: DataTypes.TINYINT, 14 | allowNull: false, 15 | validate: { 16 | min: 1 17 | } 18 | }, 19 | columnEnd: { 20 | type: DataTypes.TINYINT, 21 | allowNull: false, 22 | validate: { 23 | min: 2 24 | } 25 | }, 26 | rowStart: { 27 | type: DataTypes.TINYINT, 28 | allowNull: false, 29 | validate: { 30 | min: 1 31 | } 32 | }, 33 | rowEnd: { 34 | type: DataTypes.TINYINT, 35 | allowNull: false, 36 | validate: { 37 | min: 2 38 | } 39 | }, 40 | options: { 41 | type: DataTypes.JSON, 42 | allowNull: true 43 | } 44 | }, { 45 | hooks: { 46 | beforeCount(options: any): HookReturn { 47 | options.raw = true; 48 | } 49 | }, 50 | tableName: 'content_slots' 51 | }); 52 | 53 | (ContentSlot as any).associate = function (models: any): void { 54 | models.content_slot.belongsTo(models.view, { 55 | as: 'view' 56 | }); 57 | }; 58 | 59 | return ContentSlot; 60 | } 61 | -------------------------------------------------------------------------------- /server/src/models/displays.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const Display = sequelizeClient.define('display', { 8 | name: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | active: { 13 | type: DataTypes.BOOLEAN, 14 | allowNull: false, 15 | defaultValue: false 16 | }, 17 | description: { 18 | type: DataTypes.STRING, 19 | allowNull: false, 20 | defaultValue: '' 21 | }, 22 | type: { 23 | type: DataTypes.STRING(20), 24 | allowNull: false, 25 | defaultValue: 'monitor' 26 | } 27 | }, { 28 | hooks: { 29 | beforeCount(options: any): HookReturn { 30 | options.raw = true; 31 | } 32 | }, 33 | tableName: 'displays' 34 | }); 35 | 36 | (Display as any).associate = function (models: any): void { 37 | models.display.hasMany(models.view, { 38 | foreignKey: { allowNull: false }, 39 | as: 'views' 40 | }); 41 | models.display.hasOne(models.api_key, { 42 | foreignKey: { allowNull: true }, 43 | onDelete: 'CASCADE', 44 | onUpdate: 'CASCADE' 45 | }); 46 | }; 47 | 48 | return Display; 49 | } 50 | -------------------------------------------------------------------------------- /server/src/models/locations.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const locations = sequelizeClient.define('locations', { 8 | rawText: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | latitude: { 13 | type: DataTypes.DOUBLE, 14 | allowNull: true 15 | }, 16 | longitude: { 17 | type: DataTypes.DOUBLE, 18 | allowNull: true 19 | }, 20 | name: { 21 | type: DataTypes.STRING, 22 | allowNull: false, 23 | defaultValue: '' 24 | }, 25 | street: { 26 | type: DataTypes.STRING, 27 | allowNull: false, 28 | defaultValue: '' 29 | }, 30 | number: { 31 | type: DataTypes.STRING, 32 | allowNull: false, 33 | defaultValue: '' 34 | }, 35 | detail: { 36 | type: DataTypes.STRING, 37 | allowNull: false, 38 | defaultValue: '' 39 | }, 40 | municipality: { 41 | type: DataTypes.STRING, 42 | allowNull: false, 43 | defaultValue: '' 44 | }, 45 | district: { 46 | type: DataTypes.STRING, 47 | allowNull: false, 48 | defaultValue: '' 49 | }, 50 | hubLocationId: { 51 | type: DataTypes.INTEGER, 52 | allowNull: true 53 | } 54 | }, { 55 | hooks: { 56 | beforeCount(options: any): HookReturn { 57 | options.raw = true; 58 | } 59 | }, 60 | tableName: 'locations' 61 | }); 62 | 63 | (locations as any).associate = function (models: any): void { 64 | models.locations.belongsTo(models.incident); 65 | }; 66 | 67 | return locations; 68 | } 69 | -------------------------------------------------------------------------------- /server/src/models/settings.model.ts: -------------------------------------------------------------------------------- 1 | // See http://docs.sequelizejs.com/en/latest/docs/models-definition/ 2 | // for more of what you can do here. 3 | import { Sequelize, DataTypes, Model } from 'sequelize'; 4 | import { HookReturn } from 'sequelize/types/hooks'; 5 | import { Application } from '../declarations'; 6 | 7 | export default function (app: Application): typeof Model { 8 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 9 | const Setting = sequelizeClient.define('setting', { 10 | key: { 11 | type: DataTypes.STRING, 12 | allowNull: false, 13 | primaryKey: true 14 | }, 15 | value: { 16 | type: DataTypes.JSON, 17 | allowNull: true 18 | } 19 | }, { 20 | hooks: { 21 | beforeCount(options: any): HookReturn { 22 | options.raw = true; 23 | } 24 | }, 25 | tableName: 'settings' 26 | }); 27 | return Setting; 28 | } 29 | -------------------------------------------------------------------------------- /server/src/models/users.model.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, Sequelize } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const User = sequelizeClient.define('user', { 8 | email: { 9 | type: DataTypes.STRING, 10 | allowNull: false, 11 | unique: true 12 | }, 13 | name: { 14 | type: DataTypes.STRING, 15 | allowNull: false, 16 | defaultValue: '' 17 | }, 18 | password: { 19 | type: DataTypes.STRING, 20 | allowNull: false 21 | } 22 | }, { 23 | hooks: { 24 | beforeCount(options: any): HookReturn { 25 | options.raw = true; 26 | } 27 | }, 28 | tableName: 'users' 29 | }); 30 | return User; 31 | } 32 | -------------------------------------------------------------------------------- /server/src/models/views.model.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, DataTypes, Model } from 'sequelize'; 2 | import { HookReturn } from 'sequelize/types/hooks'; 3 | import { Application } from '../declarations'; 4 | 5 | export default function (app: Application): typeof Model { 6 | const sequelizeClient: Sequelize = app.get('sequelizeClient'); 7 | const View = sequelizeClient.define('view', { 8 | type: { 9 | type: DataTypes.STRING, 10 | allowNull: false 11 | }, 12 | order: { 13 | type: DataTypes.MEDIUMINT, 14 | allowNull: false, 15 | validate: { 16 | min: 0 17 | } 18 | }, 19 | columns: { 20 | type: DataTypes.TINYINT, 21 | allowNull: false, 22 | validate: { 23 | min: 1 24 | } 25 | }, 26 | rows: { 27 | type: DataTypes.TINYINT, 28 | allowNull: false, 29 | validate: { 30 | min: 1 31 | } 32 | }, 33 | active: { 34 | type: DataTypes.BOOLEAN, 35 | allowNull: false, 36 | defaultValue: true, 37 | }, 38 | pinned: { 39 | type: DataTypes.BOOLEAN, 40 | allowNull: false, 41 | defaultValue: false 42 | } 43 | }, { 44 | hooks: { 45 | beforeCount(options: any): HookReturn { 46 | options.raw = true; 47 | } 48 | }, 49 | tableName: 'views' 50 | }); 51 | 52 | (View as any).associate = function (models: any): void { 53 | models.view.belongsTo(models.display, { 54 | as: 'display' 55 | }); 56 | models.view.hasMany(models.content_slot, { 57 | foreignKey: { allowNull: false }, 58 | as: 'contentSlots' 59 | }); 60 | }; 61 | 62 | return View; 63 | } 64 | -------------------------------------------------------------------------------- /server/src/services/announcements/announcements.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application } from '../../declarations'; 3 | 4 | export class Announcements extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/announcements/announcements.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | // Don't remove this comment. It's needed to format import lines nicely. 4 | 5 | const { authenticate } = authentication.hooks; 6 | 7 | export default { 8 | before: { 9 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 10 | find: [], 11 | get: [], 12 | create: [], 13 | update: [], 14 | patch: [], 15 | remove: [] 16 | }, 17 | 18 | after: { 19 | all: [], 20 | find: [], 21 | get: [], 22 | create: [], 23 | update: [], 24 | patch: [], 25 | remove: [] 26 | }, 27 | 28 | error: { 29 | all: [], 30 | find: [], 31 | get: [], 32 | create: [], 33 | update: [], 34 | patch: [], 35 | remove: [] 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /server/src/services/announcements/announcements.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `announcements` service on path `/api/v1/announcements` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Announcements } from './announcements.class'; 5 | import createModel from '../../models/announcements.model'; 6 | import hooks from './announcements.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/announcements': Announcements & ServiceAddons; 12 | } 13 | 14 | interface AnnouncementData { 15 | id: number 16 | title: string 17 | body?: string 18 | important: boolean 19 | validFrom?: Date 20 | validTo?: Date 21 | } 22 | } 23 | 24 | export default function (app: Application): void { 25 | const options = { 26 | Model: createModel(app), 27 | paginate: app.get('paginate') 28 | }; 29 | 30 | // Initialize our service with any options it requires 31 | app.use('/api/v1/announcements', new Announcements(options, app)); 32 | 33 | // Get our initialized service so that we can register hooks 34 | const service = app.service('api/v1/announcements'); 35 | 36 | service.hooks(hooks); 37 | } 38 | -------------------------------------------------------------------------------- /server/src/services/api-keys/api-keys.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application } from '../../declarations'; 3 | 4 | export class ApiKeys extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/api-keys/api-keys.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `api-keys` service on path `/api/v1/api-keys` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { ApiKeys } from './api-keys.class'; 5 | import createModel from '../../models/api-keys.model'; 6 | import hooks from './api-keys.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/api-keys': ApiKeys & ServiceAddons; 12 | } 13 | } 14 | 15 | export default function (app: Application): void { 16 | const options = { 17 | Model: createModel(app), 18 | paginate: app.get('paginate') 19 | }; 20 | 21 | // Initialize our service with any options it requires 22 | app.use('/api/v1/api-keys', new ApiKeys(options, app)); 23 | 24 | // Get our initialized service so that we can register hooks 25 | const service = app.service('api/v1/api-keys'); 26 | 27 | service.hooks(hooks); 28 | } 29 | -------------------------------------------------------------------------------- /server/src/services/calendar-feeds/calendar-feeds.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application } from '../../declarations'; 3 | 4 | export interface CalendarFeedData { 5 | id: number 6 | name: string 7 | url: string 8 | } 9 | 10 | export class CalendarFeeds extends Service { 11 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | constructor(options: Partial, app: Application) { 13 | super(options); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/src/services/calendar-feeds/calendar-feeds.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | // Don't remove this comment. It's needed to format import lines nicely. 4 | 5 | const { authenticate } = authentication.hooks; 6 | 7 | export default { 8 | before: { 9 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 10 | find: [], 11 | get: [], 12 | create: [], 13 | update: [], 14 | patch: [], 15 | remove: [] 16 | }, 17 | 18 | after: { 19 | all: [], 20 | find: [], 21 | get: [], 22 | create: [], 23 | update: [], 24 | patch: [], 25 | remove: [] 26 | }, 27 | 28 | error: { 29 | all: [], 30 | find: [], 31 | get: [], 32 | create: [], 33 | update: [], 34 | patch: [], 35 | remove: [] 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /server/src/services/calendar-feeds/calendar-feeds.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `Calendar Feeds` service on path `/calendar-feeds` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { CalendarFeeds } from './calendar-feeds.class'; 5 | import createModel from '../../models/calendar-feeds.model'; 6 | import hooks from './calendar-feeds.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'calendar-feeds': CalendarFeeds & ServiceAddons; 12 | } 13 | } 14 | 15 | export default function (app: Application): void { 16 | const options = { 17 | Model: createModel(app), 18 | paginate: app.get('paginate') 19 | }; 20 | 21 | // Initialize our service with any options it requires 22 | app.use('/calendar-feeds', new CalendarFeeds(options, app)); 23 | 24 | // Get our initialized service so that we can register hooks 25 | const service = app.service('calendar-feeds'); 26 | 27 | service.hooks(hooks); 28 | } 29 | -------------------------------------------------------------------------------- /server/src/services/calendar-items/calendar-items.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { disallow } from 'feathers-hooks-common'; 3 | import { allowApiKey } from '../../hooks/allowApiKey'; 4 | // Don't remove this comment. It's needed to format import lines nicely. 5 | 6 | const { authenticate } = authentication.hooks; 7 | 8 | export default { 9 | before: { 10 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 11 | find: [], 12 | get: [], 13 | create: [ disallow('external') ], 14 | update: [ disallow('external') ], 15 | patch: [ disallow('external') ], 16 | remove: [ disallow('external') ] 17 | }, 18 | 19 | after: { 20 | all: [], 21 | find: [], 22 | get: [], 23 | create: [], 24 | update: [], 25 | patch: [], 26 | remove: [] 27 | }, 28 | 29 | error: { 30 | all: [], 31 | find: [], 32 | get: [], 33 | create: [], 34 | update: [], 35 | patch: [], 36 | remove: [] 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /server/src/services/calendar-items/calendar-items.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `Calendar Items` service on path `/calendar-items` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { CalendarItems } from './calendar-items.class'; 5 | import hooks from './calendar-items.hooks'; 6 | import { MemoryServiceOptions } from 'feathers-memory'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'calendar-items': CalendarItems & ServiceAddons; 12 | } 13 | } 14 | 15 | export default function (app: Application): void { 16 | const options: Partial = { 17 | id: 'uid', 18 | paginate: app.get('paginate'), 19 | events: ['bulk-change'], 20 | }; 21 | 22 | // Initialize our service with any options it requires 23 | app.use('/calendar-items', new CalendarItems(options, app)); 24 | 25 | // Get our initialized service so that we can register hooks 26 | const service = app.service('calendar-items'); 27 | 28 | service.hooks(hooks); 29 | } 30 | -------------------------------------------------------------------------------- /server/src/services/content-slots/content-slots.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application, ContentSlotData } from '../../declarations'; 3 | 4 | export class ContentSlots extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/content-slots/content-slots.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | import { unserializeJson } from '../../hooks/unserializeJson'; 4 | import { HookContext } from '@feathersjs/feathers'; 5 | import { getItems, replaceItems } from 'feathers-hooks-common'; 6 | import { ContentSlotData } from '../../declarations'; 7 | // Don't remove this comment. It's needed to format import lines nicely. 8 | 9 | const { authenticate } = authentication.hooks; 10 | 11 | export default { 12 | before: { 13 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 14 | find: [], 15 | get: [], 16 | create: [], 17 | update: [], 18 | patch: [], 19 | remove: [] 20 | }, 21 | 22 | after: { 23 | all: [ unserializeJson('options'), ensureDefaultOptions ], 24 | find: [], 25 | get: [], 26 | create: [], 27 | update: [], 28 | patch: [], 29 | remove: [] 30 | }, 31 | 32 | error: { 33 | all: [], 34 | find: [], 35 | get: [], 36 | create: [], 37 | update: [], 38 | patch: [], 39 | remove: [] 40 | } 41 | }; 42 | 43 | const defaultOptions = new Map([ 44 | ['AnnouncementList', { title: '' }], 45 | ['DWDWarningMap', { areaCode: 'DE', mapType: 'area' }] 46 | ]); 47 | 48 | function ensureDefaultOptions(context: HookContext): HookContext { 49 | const items = getItems(context) as ContentSlotData|ContentSlotData[]; 50 | if (Array.isArray(items)) { 51 | items.forEach(item => { 52 | item.options = Object.assign({}, defaultOptions.get(item.component) || {}, item.options); 53 | }); 54 | } else { 55 | items.options = Object.assign({}, defaultOptions.get(items.component) || {}, items.options); 56 | } 57 | 58 | replaceItems(context, items); 59 | return context; 60 | } 61 | -------------------------------------------------------------------------------- /server/src/services/content-slots/content-slots.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `content-slots` service on path `/api/v1/content-slots` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { ContentSlots } from './content-slots.class'; 5 | import createModel from '../../models/content-slots.model'; 6 | import hooks from './content-slots.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/content-slots': ContentSlots & ServiceAddons; 12 | } 13 | 14 | interface ContentSlotData { 15 | id: number 16 | component: string 17 | columnStart: number 18 | columnEnd: number 19 | rowStart: number 20 | rowEnd: number 21 | options: any 22 | viewId: number 23 | } 24 | } 25 | 26 | export default function (app: Application): void { 27 | const options = { 28 | Model: createModel(app), 29 | multi: ['remove'], 30 | paginate: app.get('paginate') 31 | }; 32 | 33 | // Initialize our service with any options it requires 34 | app.use('/api/v1/content-slots', new ContentSlots(options, app)); 35 | 36 | // Get our initialized service so that we can register hooks 37 | const service = app.service('api/v1/content-slots'); 38 | 39 | service.hooks(hooks); 40 | } 41 | -------------------------------------------------------------------------------- /server/src/services/displays/displays.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | import { shallowPopulate } from 'feathers-shallow-populate'; 4 | import { HookContext } from '@feathersjs/feathers'; 5 | // Don't remove this comment. It's needed to format import lines nicely. 6 | 7 | const { authenticate } = authentication.hooks; 8 | 9 | const populateOptions = { 10 | include: { 11 | service: 'api/v1/views', 12 | nameAs: 'views', 13 | keyHere: 'id', 14 | keyThere: 'displayId', 15 | } 16 | }; 17 | 18 | export default { 19 | before: { 20 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 21 | find: [], 22 | get: [], 23 | create: [ includeViews ], 24 | update: [], 25 | patch: [], 26 | remove: [] 27 | }, 28 | 29 | after: { 30 | all: [ shallowPopulate(populateOptions) ], 31 | find: [], 32 | get: [], 33 | create: [], 34 | update: [], 35 | patch: [], 36 | remove: [] 37 | }, 38 | 39 | error: { 40 | all: [], 41 | find: [], 42 | get: [], 43 | create: [], 44 | update: [], 45 | patch: [], 46 | remove: [] 47 | } 48 | }; 49 | 50 | /** 51 | * Automatically create nested views when creating a display 52 | * @param context 53 | */ 54 | function includeViews(context: HookContext): HookContext { 55 | const sequelize = context.app.get('sequelizeClient'); 56 | context.params.sequelize = { include: [ { model: sequelize.models.view, as: 'views' } ] }; 57 | return context; 58 | } 59 | -------------------------------------------------------------------------------- /server/src/services/displays/displays.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `displays` service on path `/api/v1/displays` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Displays } from './displays.class'; 5 | import createModel from '../../models/displays.model'; 6 | import hooks from './displays.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/displays': Displays & ServiceAddons; 12 | } 13 | 14 | interface DisplayData { 15 | id: number 16 | name: string 17 | active: boolean 18 | description: string, 19 | type: string 20 | views: ViewData[] 21 | } 22 | } 23 | 24 | export default function (app: Application): void { 25 | const options = { 26 | Model: createModel(app), 27 | paginate: app.get('paginate') 28 | }; 29 | 30 | // Initialize our service with any options it requires 31 | app.use('/api/v1/displays', new Displays(options, app)); 32 | 33 | // Get our initialized service so that we can register hooks 34 | const service = app.service('api/v1/displays'); 35 | 36 | service.hooks(hooks); 37 | } 38 | -------------------------------------------------------------------------------- /server/src/services/hub-connector/hub-connector.class.ts: -------------------------------------------------------------------------------- 1 | import { SetupMethod } from '@feathersjs/feathers'; 2 | import { Application } from '../../declarations'; 3 | import io from 'socket.io-client'; 4 | import logger from '../../logger'; 5 | import IncidentsWatcher from './services/incidents.class'; 6 | import LocationsWatcher from './services/locations.class'; 7 | 8 | export class HubConnector implements SetupMethod { 9 | app: Application; 10 | 11 | constructor (app: Application) { 12 | this.app = app; 13 | } 14 | 15 | setup(app: Application): void { 16 | const hubHost = app.get('hub_host'); 17 | const hubApiKey = app.get('hub_api_key'); 18 | 19 | // If no host is set, there is no intention to connect to the Hub 20 | if (!hubHost || hubHost === '' || hubHost === 'HUB_HOST') { 21 | return; 22 | } 23 | 24 | let hubUrl: URL; 25 | try { 26 | hubUrl = new URL(hubHost); 27 | } catch { 28 | logger.error('hub_host is not a valid URL'); 29 | return; 30 | } 31 | 32 | if (!hubApiKey || hubApiKey === '' || hubApiKey === 'HUB_API_KEY') { 33 | logger.warn('hub_host is set, but hub_api_key is empty'); 34 | return; 35 | } 36 | 37 | (app.get('databaseReady') as Promise).then(() => { 38 | logger.info('Connecting to Hub at %s...', hubUrl.toString()); 39 | const socket = io(hubUrl.toString(), { 40 | transportOptions: { 41 | polling: { 42 | extraHeaders: { 43 | 'x-api-key': hubApiKey 44 | } 45 | } 46 | } 47 | }); 48 | socket.on('connect', () => { 49 | logger.info('Connected to Hub'); 50 | }); 51 | socket.on('disconnect', (reason: Error) => { 52 | logger.error('Disconnected from Hub:', reason); 53 | }); 54 | socket.on('connect_error', (reason: Error) => { 55 | logger.error('Error connecting to Hub:', reason.message); 56 | }); 57 | socket.on('connect_timeout', (reason: Error) => { 58 | logger.error('Timeout connecting to Hub:', reason); 59 | }); 60 | 61 | // Start watching services on the Hub 62 | new IncidentsWatcher(app, socket); 63 | new LocationsWatcher(app, socket); 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/services/hub-connector/hub-connector.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { disallow } from 'feathers-hooks-common'; 3 | // Don't remove this comment. It's needed to format import lines nicely. 4 | 5 | const { authenticate } = authentication.hooks; 6 | 7 | export default { 8 | before: { 9 | all: [ disallow('external'), authenticate('jwt') ], 10 | find: [], 11 | get: [], 12 | create: [], 13 | update: [], 14 | patch: [], 15 | remove: [] 16 | }, 17 | 18 | after: { 19 | all: [], 20 | find: [], 21 | get: [], 22 | create: [], 23 | update: [], 24 | patch: [], 25 | remove: [] 26 | }, 27 | 28 | error: { 29 | all: [], 30 | find: [], 31 | get: [], 32 | create: [], 33 | update: [], 34 | patch: [], 35 | remove: [] 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /server/src/services/hub-connector/hub-connector.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `hub-connector` service on path `/hub-connector` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { HubConnector } from './hub-connector.class'; 5 | import hooks from './hub-connector.hooks'; 6 | 7 | // Add this service to the service type index 8 | declare module '../../declarations' { 9 | interface ServiceTypes { 10 | 'hub-connector': HubConnector & ServiceAddons; 11 | } 12 | } 13 | 14 | export default function (app: Application): void { 15 | app.use('/hub-connector', new HubConnector(app)); 16 | 17 | // Get our initialized service so that we can register hooks 18 | const service = app.service('hub-connector'); 19 | 20 | service.hooks(hooks); 21 | } 22 | -------------------------------------------------------------------------------- /server/src/services/incidents/incidents.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application, IncidentData } from '../../declarations'; 3 | 4 | export class Incidents extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/incidents/incidents.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | import { HookContext } from '@feathersjs/feathers'; 4 | import { shallowPopulate } from 'feathers-shallow-populate'; 5 | // Don't remove this comment. It's needed to format import lines nicely. 6 | 7 | const { authenticate } = authentication.hooks; 8 | 9 | // TODO make sure that incidents that are mirrored from the Hub do not get modified 10 | // TODO make sure that local incidents cannot be assigned a hubIncidentId 11 | 12 | const populateOptions = { 13 | include: { 14 | service: 'api/v1/locations', 15 | nameAs: 'location', 16 | keyHere: 'id', 17 | keyThere: 'incidentId', 18 | asArray: false 19 | } 20 | }; 21 | 22 | export default { 23 | before: { 24 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 25 | find: [], 26 | get: [], 27 | create: [ includeAssociations ], 28 | update: [], 29 | patch: [], 30 | remove: [] 31 | }, 32 | 33 | after: { 34 | all: [ shallowPopulate(populateOptions) ], 35 | find: [], 36 | get: [], 37 | create: [], 38 | update: [], 39 | patch: [], 40 | remove: [] 41 | }, 42 | 43 | error: { 44 | all: [], 45 | find: [], 46 | get: [], 47 | create: [], 48 | update: [], 49 | patch: [], 50 | remove: [] 51 | } 52 | }; 53 | 54 | async function includeAssociations(context: HookContext): Promise { 55 | if (context.data.location) { 56 | const LocationService = context.app.service('api/v1/locations'); 57 | context.params.sequelize = { 58 | include: [{ model: LocationService.Model }] 59 | }; 60 | } 61 | return context; 62 | } 63 | -------------------------------------------------------------------------------- /server/src/services/incidents/incidents.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `incidents` service on path `/api/v1/incidents` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Incidents } from './incidents.class'; 5 | import createModel from '../../models/incidents.model'; 6 | import hooks from './incidents.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/incidents': Incidents & ServiceAddons; 12 | } 13 | 14 | interface IncidentData { 15 | id: number 16 | time: Date 17 | sender: string 18 | ref: string 19 | caller_name: string 20 | caller_number: string 21 | location?: LocationData 22 | reason: string 23 | keyword: string 24 | description: string 25 | status: 'Actual'|'Exercise'|'Test' 26 | category: 'Geo'|'Met'|'Safety'|'Security'|'Rescue'|'Fire'|'Health'|'Env'|'Transport'|'Infra'|'CBRNE'|'Other' 27 | hubIncidentId?: number 28 | } 29 | } 30 | 31 | export default function (app: Application): void { 32 | const options = { 33 | Model: createModel(app), 34 | paginate: app.get('paginate'), 35 | multi: ['remove'] 36 | }; 37 | 38 | // Initialize our service with any options it requires 39 | app.use('/api/v1/incidents', new Incidents(options, app)); 40 | 41 | // Get our initialized service so that we can register hooks 42 | const service = app.service('api/v1/incidents'); 43 | 44 | service.hooks(hooks); 45 | } 46 | -------------------------------------------------------------------------------- /server/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../declarations'; 2 | import users from './users/users.service'; 3 | import incidents from './incidents/incidents.service'; 4 | import displays from './displays/displays.service'; 5 | import apiKeys from './api-keys/api-keys.service'; 6 | import views from './views/views.service'; 7 | import contentSlots from './content-slots/content-slots.service'; 8 | import locations from './locations/locations.service'; 9 | import announcements from './announcements/announcements.service'; 10 | import hubConnector from './hub-connector/hub-connector.service'; 11 | import keyRequests from './key-requests/key-requests.service'; 12 | import settings from './settings/settings.service'; 13 | import status from './status/status.service'; 14 | import calendarFeeds from './calendar-feeds/calendar-feeds.service'; 15 | import calendarItems from './calendar-items/calendar-items.service'; 16 | // Don't remove this comment. It's needed to format import lines nicely. 17 | 18 | export default function (app: Application): void { 19 | app.configure(users); 20 | app.configure(incidents); 21 | app.configure(displays); 22 | app.configure(apiKeys); 23 | app.configure(views); 24 | app.configure(contentSlots); 25 | app.configure(locations); 26 | app.configure(announcements); 27 | app.configure(hubConnector); 28 | app.configure(keyRequests); 29 | app.configure(settings); 30 | app.configure(status); 31 | app.configure(calendarFeeds); 32 | app.configure(calendarItems); 33 | } 34 | -------------------------------------------------------------------------------- /server/src/services/key-requests/key-requests.class.ts: -------------------------------------------------------------------------------- 1 | import { MemoryServiceOptions, Service } from 'feathers-memory'; 2 | import { Application, DisplayData, KeyRequestData } from '../../declarations'; 3 | import { NullableId, Params } from '@feathersjs/feathers'; 4 | import { getIdentifierForConnection } from '../../channels'; 5 | import { MethodNotAllowed } from '@feathersjs/errors'; 6 | 7 | export class KeyRequests extends Service { 8 | private app: Application; 9 | constructor(options: Partial, app: Application) { 10 | super(options); 11 | this.app = app; 12 | } 13 | 14 | _create(data: Partial, params?: Params): Promise { 15 | if (!params?.connection) { 16 | throw new MethodNotAllowed('A key request can only be created by realtime connections'); 17 | } 18 | 19 | // Every unauthenticated connection gets an identifier that can be used to identify its key request 20 | const identifier = getIdentifierForConnection(params.connection); 21 | if (!identifier) { 22 | throw new Error('Unknown connection'); 23 | } 24 | data.requestId = identifier; 25 | 26 | // No request may be granted from the beginning 27 | data.granted = false; 28 | 29 | return super._create(data, params); 30 | } 31 | 32 | 33 | async _patch(id: NullableId, data: Partial, params?: Params): Promise { 34 | if (id === null) { 35 | throw new MethodNotAllowed('Can not patch multiple entries'); 36 | } 37 | 38 | const existingRecord = await this.get(id); 39 | // If the key request gets granted, create an API key, connected to a new Display 40 | if (!existingRecord.granted && data.granted === true) { 41 | const newDisplay = await this.app.service('api/v1/displays').create({ 42 | name: data.name || 'Neues Display', 43 | active: true 44 | }) as DisplayData; 45 | const newApiKey = await this.app.service('api/v1/api-keys').create({ 46 | name: newDisplay.name, 47 | displayId: newDisplay.id 48 | }); 49 | data.apiKey = `${newApiKey.id}:${newApiKey.token}`; 50 | } 51 | 52 | return await super._patch(id, data, params); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/services/key-requests/key-requests.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { disallow } from 'feathers-hooks-common'; 3 | 4 | const { authenticate } = authentication.hooks; 5 | 6 | export default { 7 | before: { 8 | all: [], 9 | find: [ authenticate('jwt') ], 10 | get: [ authenticate('jwt') ], 11 | create: [ disallow('rest')], 12 | update: [ authenticate('jwt') ], 13 | patch: [ authenticate('jwt') ], 14 | remove: [ authenticate('jwt') ] 15 | }, 16 | 17 | after: { 18 | all: [], 19 | find: [], 20 | get: [], 21 | create: [], 22 | update: [], 23 | patch: [], 24 | remove: [] 25 | }, 26 | 27 | error: { 28 | all: [], 29 | find: [], 30 | get: [], 31 | create: [], 32 | update: [], 33 | patch: [], 34 | remove: [] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /server/src/services/key-requests/key-requests.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `key-requests` service on path `/api/v1/key-requests` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { KeyRequests } from './key-requests.class'; 5 | import hooks from './key-requests.hooks'; 6 | 7 | // Add this service to the service type index 8 | declare module '../../declarations' { 9 | interface ServiceTypes { 10 | 'api/v1/key-requests': KeyRequests & ServiceAddons; 11 | } 12 | 13 | interface KeyRequestData { 14 | name: string 15 | granted: boolean 16 | requestId: string 17 | apiKey: string 18 | } 19 | } 20 | 21 | export default function (app: Application): void { 22 | const options = { 23 | paginate: app.get('paginate'), 24 | multi: ['remove'] 25 | }; 26 | 27 | // Initialize our service with any options it requires 28 | app.use('/api/v1/key-requests', new KeyRequests(options, app)); 29 | 30 | // Get our initialized service so that we can register hooks 31 | const service = app.service('api/v1/key-requests'); 32 | 33 | service.hooks(hooks); 34 | } 35 | -------------------------------------------------------------------------------- /server/src/services/locations/locations.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application, LocationData } from '../../declarations'; 3 | 4 | export class Locations extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/locations/locations.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | // Don't remove this comment. It's needed to format import lines nicely. 4 | 5 | const { authenticate } = authentication.hooks; 6 | 7 | // TODO make sure that locations that are mirrored from the Hub do not get modified 8 | // TODO make sure that local locations cannot be assigned a hubLocationId 9 | 10 | export default { 11 | before: { 12 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 13 | find: [], 14 | get: [], 15 | create: [], 16 | update: [], 17 | patch: [], 18 | remove: [] 19 | }, 20 | 21 | after: { 22 | all: [], 23 | find: [], 24 | get: [], 25 | create: [], 26 | update: [], 27 | patch: [], 28 | remove: [] 29 | }, 30 | 31 | error: { 32 | all: [], 33 | find: [], 34 | get: [], 35 | create: [], 36 | update: [], 37 | patch: [], 38 | remove: [] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /server/src/services/locations/locations.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `locations` service on path `/api/v1/locations` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Locations } from './locations.class'; 5 | import createModel from '../../models/locations.model'; 6 | import hooks from './locations.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/locations': Locations & ServiceAddons; 12 | } 13 | 14 | interface LocationData { 15 | id?: number 16 | rawText: string 17 | latitude?: number 18 | longitude?: number 19 | name: string 20 | street: string 21 | number: string 22 | detail: string 23 | municipality: string 24 | district: string 25 | incidentId?: number 26 | hubLocationId?: number 27 | } 28 | } 29 | 30 | export default function (app: Application): void { 31 | const options = { 32 | Model: createModel(app), 33 | multi: ['remove'], 34 | paginate: app.get('paginate') 35 | }; 36 | 37 | // Initialize our service with any options it requires 38 | app.use('/api/v1/locations', new Locations(options, app)); 39 | 40 | // Get our initialized service so that we can register hooks 41 | const service = app.service('api/v1/locations'); 42 | 43 | service.hooks(hooks); 44 | } 45 | -------------------------------------------------------------------------------- /server/src/services/settings/settings.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application, SettingsData, SettingsValue } from '../../declarations'; 3 | import logger from '../../logger'; 4 | 5 | export class Settings extends Service { 6 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | constructor(options: Partial, app: Application) { 8 | super(options); 9 | } 10 | 11 | setup(app: Application): void { 12 | const settingDefaults = new Map([ 13 | ['alert_banner_message', ''], 14 | ['incident_display_minutes', 60], 15 | ['incident_test_display_minutes', 1], 16 | ['reason_display_regex', null], 17 | ['station_coordinates', null], 18 | ]); 19 | 20 | (app.get('databaseReady') as Promise).then(async () => { 21 | logger.debug('Checking the settings table'); 22 | for (const [settingKey, settingDefault] of settingDefaults) { 23 | try { 24 | await this.get(settingKey); 25 | } catch { 26 | // If the setting cannot be found, create it with the default value 27 | await this.create({ key: settingKey, value: settingDefault }); 28 | } 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/services/settings/settings.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { allowApiKey } from '../../hooks/allowApiKey'; 3 | import { disallow } from 'feathers-hooks-common'; 4 | import { unserializeJson } from '../../hooks/unserializeJson'; 5 | // Don't remove this comment. It's needed to format import lines nicely. 6 | 7 | const { authenticate } = authentication.hooks; 8 | 9 | export default { 10 | before: { 11 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 12 | find: [], 13 | get: [], 14 | create: [ disallow('external') ], 15 | update: [], 16 | patch: [], 17 | remove: [ disallow('external') ] 18 | }, 19 | 20 | after: { 21 | all: [ unserializeJson('value') ], 22 | find: [], 23 | get: [], 24 | create: [], 25 | update: [], 26 | patch: [], 27 | remove: [] 28 | }, 29 | 30 | error: { 31 | all: [], 32 | find: [], 33 | get: [], 34 | create: [], 35 | update: [], 36 | patch: [], 37 | remove: [] 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /server/src/services/settings/settings.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `settings` service on path `/api/v1/settings` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Settings } from './settings.class'; 5 | import createModel from '../../models/settings.model'; 6 | import hooks from './settings.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/settings': Settings & ServiceAddons; 12 | } 13 | 14 | interface SettingsData { 15 | key: string 16 | value: SettingsValue 17 | } 18 | 19 | interface CoordinateValue { 20 | latitude: number 21 | longitude: number 22 | } 23 | 24 | type SettingsValue = null | string | number | CoordinateValue 25 | } 26 | 27 | export default function (app: Application): void { 28 | const options = { 29 | Model: createModel(app), 30 | id: 'key', 31 | paginate: app.get('paginate') 32 | }; 33 | 34 | // Initialize our service with any options it requires 35 | app.use('/api/v1/settings', new Settings(options, app)); 36 | 37 | // Get our initialized service so that we can register hooks 38 | const service = app.service('api/v1/settings'); 39 | 40 | service.hooks(hooks); 41 | } 42 | -------------------------------------------------------------------------------- /server/src/services/status/status.class.ts: -------------------------------------------------------------------------------- 1 | import { Params, SetupMethod } from '@feathersjs/feathers'; 2 | import { Application } from '../../declarations'; 3 | 4 | interface StatusData { 5 | ready: boolean 6 | } 7 | 8 | export class Status implements SetupMethod { 9 | app: Application; 10 | databaseReady = false; 11 | 12 | constructor (app: Application) { 13 | this.app = app; 14 | } 15 | 16 | setup(app: Application) { 17 | // Since we cannot get the Promise's status, we have to set a local variable upon fulfilment 18 | (app.get('databaseReady') as Promise).then(() => { 19 | this.databaseReady = true; 20 | }); 21 | } 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | async find (params?: Params): Promise { 25 | return { 26 | ready: this.databaseReady 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/services/status/status.hooks.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | before: { 3 | all: [], 4 | find: [], 5 | get: [], 6 | create: [], 7 | update: [], 8 | patch: [], 9 | remove: [] 10 | }, 11 | 12 | after: { 13 | all: [], 14 | find: [], 15 | get: [], 16 | create: [], 17 | update: [], 18 | patch: [], 19 | remove: [] 20 | }, 21 | 22 | error: { 23 | all: [], 24 | find: [], 25 | get: [], 26 | create: [], 27 | update: [], 28 | patch: [], 29 | remove: [] 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /server/src/services/status/status.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `status` service on path `/status` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Status } from './status.class'; 5 | import hooks from './status.hooks'; 6 | 7 | // Add this service to the service type index 8 | declare module '../../declarations' { 9 | interface ServiceTypes { 10 | 'status': Status & ServiceAddons; 11 | } 12 | } 13 | 14 | export default function (app: Application): void { 15 | // Initialize our service with any options it requires 16 | app.use('/status', new Status(app)); 17 | 18 | // Get our initialized service so that we can register hooks 19 | const service = app.service('status'); 20 | 21 | service.hooks(hooks); 22 | } 23 | -------------------------------------------------------------------------------- /server/src/services/users/users.class.ts: -------------------------------------------------------------------------------- 1 | import { Service, SequelizeServiceOptions } from 'feathers-sequelize'; 2 | import { Application } from '../../declarations'; 3 | 4 | export class Users extends Service { 5 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | constructor(options: Partial, app: Application) { 7 | super(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/services/users/users.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as feathersAuthentication from '@feathersjs/authentication'; 2 | import * as local from '@feathersjs/authentication-local'; 3 | import { HookContext } from '@feathersjs/feathers'; 4 | import { BadRequest } from '@feathersjs/errors'; 5 | // Don't remove this comment. It's needed to format import lines nicely. 6 | 7 | const { authenticate } = feathersAuthentication.hooks; 8 | const { hashPassword, protect } = local.hooks; 9 | 10 | function preventEmptyPassword (context: HookContext): HookContext { 11 | if ((context.method === 'create' || context.method === 'update') && !context.data.password) { 12 | throw new BadRequest('Password must not be empty'); 13 | } 14 | 15 | if (context.data.password && context.data.password === '') { 16 | throw new BadRequest('Password must not be empty'); 17 | } 18 | 19 | return context; 20 | } 21 | 22 | /** 23 | * Require the request to be authenticated, except when creating the first user 24 | * 25 | * @param context 26 | */ 27 | async function maybeAuthenticate(this: any, context: HookContext): Promise { 28 | const existingUsers = await context.service.find({ query: { $limit: 0 } }); 29 | 30 | if (existingUsers.total > 0) { 31 | return authenticate('jwt').call(this, context); 32 | } 33 | 34 | return context; 35 | } 36 | 37 | export default { 38 | before: { 39 | all: [], 40 | find: [ authenticate('jwt') ], 41 | get: [ authenticate('jwt') ], 42 | create: [ preventEmptyPassword, hashPassword('password'), maybeAuthenticate ], 43 | update: [ preventEmptyPassword, hashPassword('password'), authenticate('jwt') ], 44 | patch: [ preventEmptyPassword, hashPassword('password'), authenticate('jwt') ], 45 | remove: [ authenticate('jwt') ] 46 | }, 47 | 48 | after: { 49 | all: [ 50 | // Make sure the password field is never sent to the client 51 | // Always must be the last hook 52 | protect('password') 53 | ], 54 | find: [], 55 | get: [], 56 | create: [], 57 | update: [], 58 | patch: [], 59 | remove: [] 60 | }, 61 | 62 | error: { 63 | all: [], 64 | find: [], 65 | get: [], 66 | create: [], 67 | update: [], 68 | patch: [], 69 | remove: [] 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /server/src/services/users/users.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `users` service on path `/users` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Users } from './users.class'; 5 | import createModel from '../../models/users.model'; 6 | import hooks from './users.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'users': Users & ServiceAddons; 12 | } 13 | } 14 | 15 | export default function (app: Application): void { 16 | const options = { 17 | Model: createModel(app), 18 | paginate: app.get('paginate') 19 | }; 20 | 21 | // Initialize our service with any options it requires 22 | app.use('/users', new Users(options, app)); 23 | 24 | // Get our initialized service so that we can register hooks 25 | const service = app.service('users'); 26 | 27 | service.hooks(hooks); 28 | } 29 | -------------------------------------------------------------------------------- /server/src/services/views/views.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from '@feathersjs/authentication'; 2 | import { shallowPopulate } from 'feathers-shallow-populate'; 3 | import { allowApiKey } from '../../hooks/allowApiKey'; 4 | import { HookContext } from '@feathersjs/feathers'; 5 | // Don't remove this comment. It's needed to format import lines nicely. 6 | 7 | const { authenticate } = authentication.hooks; 8 | 9 | const populateOptions = { 10 | include: { 11 | service: 'api/v1/content-slots', 12 | nameAs: 'contentSlots', 13 | keyHere: 'id', 14 | keyThere: 'viewId', 15 | } 16 | }; 17 | 18 | export default { 19 | before: { 20 | all: [ allowApiKey(), authenticate('jwt', 'api-key') ], 21 | find: [], 22 | get: [], 23 | create: [ includeContentSlots ], 24 | update: [], 25 | patch: [], 26 | remove: [] 27 | }, 28 | 29 | after: { 30 | all: [ shallowPopulate(populateOptions) ], 31 | find: [], 32 | get: [], 33 | create: [], 34 | update: [], 35 | patch: [], 36 | remove: [] 37 | }, 38 | 39 | error: { 40 | all: [], 41 | find: [], 42 | get: [], 43 | create: [], 44 | update: [], 45 | patch: [], 46 | remove: [] 47 | } 48 | }; 49 | 50 | /** 51 | * Automatically create nested content slots when creating a view 52 | * @param context 53 | */ 54 | function includeContentSlots(context: HookContext): HookContext { 55 | const sequelize = context.app.get('sequelizeClient'); 56 | context.params.sequelize = { include: [ { model: sequelize.models.content_slot, as: 'contentSlots' } ] }; 57 | return context; 58 | } 59 | -------------------------------------------------------------------------------- /server/src/services/views/views.service.ts: -------------------------------------------------------------------------------- 1 | // Initializes the `views` service on path `/api/v1/views` 2 | import { ServiceAddons } from '@feathersjs/feathers'; 3 | import { Application } from '../../declarations'; 4 | import { Views } from './views.class'; 5 | import createModel from '../../models/views.model'; 6 | import hooks from './views.hooks'; 7 | 8 | // Add this service to the service type index 9 | declare module '../../declarations' { 10 | interface ServiceTypes { 11 | 'api/v1/views': Views & ServiceAddons; 12 | } 13 | 14 | interface ViewData { 15 | id: number 16 | type: string 17 | order: number 18 | columns: number 19 | rows: number 20 | displayId: number 21 | active: boolean 22 | pinned: boolean 23 | contentSlots: ContentSlotData[] 24 | } 25 | } 26 | 27 | export default function (app: Application): void { 28 | const options = { 29 | Model: createModel(app), 30 | multi: ['remove'], 31 | paginate: app.get('paginate') 32 | }; 33 | 34 | // Initialize our service with any options it requires 35 | app.use('/api/v1/views', new Views(options, app)); 36 | 37 | // Get our initialized service so that we can register hooks 38 | const service = app.service('api/v1/views'); 39 | 40 | service.hooks(hooks); 41 | } 42 | -------------------------------------------------------------------------------- /server/test/app.test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import url from 'url'; 3 | import axios, { AxiosError } from 'axios'; 4 | 5 | import app from '../src/app'; 6 | 7 | const port = app.get('port') || 8998; 8 | const getUrl = (pathname?: string): string => url.format({ 9 | hostname: app.get('host') || 'localhost', 10 | protocol: 'http', 11 | port, 12 | pathname 13 | }); 14 | 15 | describe('Feathers application tests (with jest)', () => { 16 | let server: Server; 17 | 18 | beforeAll(done => { 19 | server = app.listen(port); 20 | (app.get('databaseReady') as Promise).then(done); 21 | }, 60000); 22 | 23 | afterAll(done => { 24 | server.close(done); 25 | }); 26 | 27 | it('starts and shows the index page', async () => { 28 | expect.assertions(1); 29 | 30 | const { data } = await axios.get(getUrl()); 31 | 32 | expect(data.indexOf('')).not.toBe(-1); 33 | }); 34 | 35 | describe('404', () => { 36 | it('shows a 404 HTML page', async () => { 37 | expect.assertions(4); 38 | 39 | try { 40 | await axios.get(getUrl('path/to/nowhere'), { 41 | headers: { 42 | 'Accept': 'text/html' 43 | } 44 | }); 45 | } catch (error) { 46 | expect(error).toBeInstanceOf(AxiosError); 47 | const { response } = error as AxiosError; 48 | 49 | expect(response?.status).toBe(404); 50 | expect(typeof response?.data).toBe('string'); 51 | expect((response?.data as string).startsWith('')).toBeTruthy(); 52 | } 53 | }); 54 | 55 | it('shows a 404 JSON error without stack trace', async () => { 56 | expect.assertions(3); 57 | 58 | try { 59 | await axios.get(getUrl('path/to/nowhere')); 60 | } catch (error) { 61 | expect(error).toBeInstanceOf(AxiosError); 62 | const { response } = error as AxiosError; 63 | 64 | expect(response?.status).toBe(404); 65 | expect(response?.data).toMatchObject({ 66 | code: 404, 67 | message: 'Page not found', 68 | name: 'NotFound' 69 | }); 70 | } 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /server/test/authentication.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../src/app'; 2 | 3 | describe('authentication', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | // Wait for the database to be migrated / synced 7 | (app.get('databaseReady') as Promise).then(done); 8 | }); 9 | 10 | it('registered the authentication service', () => { 11 | expect(app.service('authentication')).toBeTruthy(); 12 | }); 13 | 14 | describe('local strategy', () => { 15 | const userInfo = { 16 | email: 'someone@example.com', 17 | password: 'supersecret' 18 | }; 19 | 20 | beforeAll((done) => { 21 | // Wait for the database to be migrated / synced 22 | app.service('users').create(userInfo) 23 | .then(() => done(), () => { 24 | // Do nothing, it just means the user already exists and can be tested 25 | done(); 26 | }); 27 | }); 28 | 29 | it('authenticates user and creates accessToken', async () => { 30 | const { user, accessToken } = await app.service('authentication').create({ 31 | strategy: 'local', 32 | ...userInfo 33 | }, {}); 34 | 35 | expect(accessToken).toBeTruthy(); 36 | expect(user).toBeTruthy(); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /server/test/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import mainConfig from '../eslint.config.mjs'; 3 | 4 | export default [ 5 | ...mainConfig, 6 | { 7 | languageOptions: { 8 | globals: { 9 | ...globals.jest, 10 | }, 11 | }, 12 | }]; 13 | -------------------------------------------------------------------------------- /server/test/services/announcements.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'announcements\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/announcements'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/api-keys.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'api-keys\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/api-keys'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/calendar-feeds.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'Calendar Feeds\' service', () => { 4 | it('registered the service', () => { 5 | const service = app.service('calendar-feeds'); 6 | expect(service).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /server/test/services/calendar-items.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, jest, it } from '@jest/globals'; 2 | import app from '../../src/app'; 3 | import axios from 'axios'; 4 | import { CalendarItemData } from '../../src/services/calendar-items/calendar-items.class'; 5 | import fs from 'fs/promises'; 6 | import path from 'path'; 7 | 8 | jest.mock('axios'); 9 | 10 | describe('\'Calendar Items\' service', () => { 11 | beforeAll(done => { 12 | app.setup(); 13 | (app.get('databaseReady') as Promise).then(done); 14 | }); 15 | 16 | it('registered the service', () => { 17 | const service = app.service('calendar-items'); 18 | expect(service).toBeTruthy(); 19 | }); 20 | 21 | it('should fetch a calendar feed', async () => { 22 | const testFeed = await fs.readFile(path.resolve(__dirname, '../feed.ics'), { encoding: 'utf-8' }); 23 | axios.get.mockResolvedValue({ data: testFeed }); 24 | 25 | jest.useFakeTimers(); 26 | jest.setSystemTime(1723149778415); // simulate Thu Aug 08 2024 20:42:58 GMT+0000 27 | 28 | const calendarFeedsService = app.service('calendar-feeds'); 29 | await calendarFeedsService.create({ name: 'Test', url: 'https://example.org/testfeed.ics' }); 30 | 31 | const service = app.service('calendar-items'); 32 | const items = await service.find({ paginate: false }) as CalendarItemData[]; 33 | 34 | expect(axios.get).toHaveBeenCalledWith('https://example.org/testfeed.ics'); 35 | expect(items.length).toBe(9); 36 | expect(items.filter(item => item.summary === 'Recurring event weekly').length).toBe(5); 37 | expect(items.filter(item => item.summary === 'Recurring event weekly only twice').length).toBe(2); 38 | expect(items.filter(item => item.summary === 'Future Event').length).toBe(1); 39 | expect(items.filter(item => item.summary === 'Past Event').length).toBe(0); 40 | expect(items.filter(item => item.summary === 'Whole day').length).toBe(1); 41 | 42 | jest.useRealTimers(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /server/test/services/content-slots.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'content-slots\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/content-slots'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/displays.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | const servicePath = 'api/v1/displays'; 4 | 5 | describe('\'displays\' service', () => { 6 | beforeAll(done => { 7 | app.setup(); 8 | (app.get('databaseReady') as Promise).then(done); 9 | 10 | }); 11 | it('registered the service', () => { 12 | const service = app.service(servicePath); 13 | expect(service).toBeTruthy(); 14 | }); 15 | 16 | it('creates a Display with just a name', async () => { 17 | const service = app.service(servicePath); 18 | const display = await service.create({ name: 'Test-Display' }); 19 | expect(display).toHaveProperty('id'); 20 | expect(display).toHaveProperty('name', 'Test-Display'); 21 | expect(display).toHaveProperty('description', ''); 22 | expect(display).toHaveProperty('active', false); 23 | expect(display).toHaveProperty('views', []); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/test/services/hub-connector.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'hub-connector\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('hub-connector'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/incidents.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'incidents\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/incidents'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/key-requests.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'key-requests\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/key-requests'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/locations.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'locations\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/locations'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/settings.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'settings\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/settings'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/status.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'status\' service', () => { 4 | it('registered the service', () => { 5 | const service = app.service('status'); 6 | expect(service).toBeTruthy(); 7 | }); 8 | 9 | it('should return the ready status', async () => { 10 | const statusData = await app.service('status').find(); 11 | expect(statusData).toEqual(expect.objectContaining({ ready: expect.any(Boolean) })); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/users.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'users\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('users'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/services/views.test.ts: -------------------------------------------------------------------------------- 1 | import app from '../../src/app'; 2 | 3 | describe('\'views\' service', () => { 4 | beforeAll(done => { 5 | app.setup(); 6 | (app.get('databaseReady') as Promise).then(done); 7 | }); 8 | 9 | it('registered the service', () => { 10 | const service = app.service('api/v1/views'); 11 | expect(service).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/test/testSequencer.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-require-imports 2 | const Sequencer = require('@jest/test-sequencer').default; 3 | 4 | class CustomSequencer extends Sequencer { 5 | sort(tests) { 6 | const copyTests = Array.from(tests); 7 | return copyTests.sort((testA, testB) => (testA.path > testB.path ? 1 : -1)); 8 | } 9 | } 10 | 11 | module.exports = CustomSequencer; 12 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "typeRoots" : ["./node_modules/@types", "./typings"], 9 | "esModuleInterop": true 10 | }, 11 | "exclude": [ 12 | "test" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /server/typings/feathers-shallow-populate/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for feathers-shallow-populate 2 | declare module 'feathers-shallow-populate' { 3 | import { Hook, HookContext, Params } from '@feathersjs/feathers'; 4 | 5 | interface Include { 6 | service: string 7 | nameAs: string 8 | keyHere?: string 9 | keyThere?: string 10 | asArray?: boolean 11 | requestPerItem?: boolean 12 | catchOnError?: boolean 13 | params?: Params | typeof ParamsFunction 14 | } 15 | 16 | function ParamsFunction(params?: Params, context?: HookContext): Params | Promise | undefined; 17 | 18 | export interface PopulateOptions { 19 | include: (Include | Include[]) 20 | catchOnError?: boolean 21 | } 22 | 23 | export function shallowPopulate(options: PopulateOptions): Hook 24 | } 25 | -------------------------------------------------------------------------------- /test-api/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /test-api/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /test-api/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test-api/.idea/test-api.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test-api/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test-api/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec": ["spec/**/*.js"], 3 | "require": ["fixtures.mjs"], 4 | "sort": true 5 | } 6 | -------------------------------------------------------------------------------- /test-api/fixtures.mjs: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | let should = chai.should(); 3 | 4 | const serverAddress = process.env.SERVER_URL ? process.env.SERVER_URL : 'http://localhost:3031' 5 | const url = new URL(serverAddress) 6 | url.protocol.should.be.oneOf(['http:', 'https:'], 'The URL must begin with http or https') 7 | console.log(`Using ${url.origin} as base for all requests`); 8 | 9 | /** 10 | * Preparations that need to happen, before the tests can start. 11 | * 12 | * @return {Promise} 13 | */ 14 | export async function mochaGlobalSetup() { 15 | const maxAttempts = 40 16 | console.log(`Trying to connect to ${url.origin} and determine the ready state ...`); 17 | for (let i = 0; i < maxAttempts; i++) { 18 | if (i > 0) { 19 | // Wait a bit between attempts 20 | await sleep(3000) 21 | } 22 | 23 | try { 24 | const res = await chai.request(url.origin).get('/status') 25 | res.should.have.status(200); 26 | res.body.should.include({ ready: true }); 27 | console.log('[OK] The server is reachable and ready'); 28 | break 29 | } catch (e) { 30 | // Only throw, if maxAttempts is reached 31 | if (i === maxAttempts - 1) { 32 | throw e 33 | } 34 | } 35 | } 36 | } 37 | 38 | export const mochaHooks = { 39 | beforeAll(done) { 40 | this.server = { 41 | base: url.origin 42 | } 43 | done(); 44 | } 45 | }; 46 | 47 | async function sleep (duration) { 48 | return new Promise(resolve => { 49 | setTimeout(resolve, duration) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /test-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alarmdisplay/display-api-test", 3 | "version": "1.0.0", 4 | "description": "Tests the REST API from a client's perspective", 5 | "scripts": { 6 | "start": "mocha" 7 | }, 8 | "author": "Andreas Brain", 9 | "devDependencies": { 10 | "chai": "5.2.0", 11 | "chai-http": "5.1.2", 12 | "mocha": "11.3.0" 13 | } 14 | } 15 | --------------------------------------------------------------------------------