diff --git a/client/admin/admin.jsx b/client/admin/admin.jsx
index 92e0b2aee..f2f2667a4 100644
--- a/client/admin/admin.jsx
+++ b/client/admin/admin.jsx
@@ -2,35 +2,44 @@ require('./admin.less');
const React = require('react');
const createClass = require('create-react-class');
+const BrewUtils = require('./brewUtils/brewUtils.jsx');
+const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
-const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
-const BrewLookup = require('./brewLookup/brewLookup.jsx');
-const BrewCompress = require ('./brewCompress/brewCompress.jsx');
-const Stats = require('./stats/stats.jsx');
+const tabGroups = ['brew', 'notifications'];
const Admin = createClass({
getDefaultProps : function() {
return {};
},
+ getInitialState : function(){
+ return ({
+ currentTab : 'brew'
+ });
+ },
+
+ handleClick : function(newTab){
+ if(this.state.currentTab === newTab) return;
+ this.setState({
+ currentTab : newTab
+ });
+ },
+
render : function(){
return
-
-
-
-
-
-
-
-
-
-
+
+
+ {this.state.currentTab==='brew' && }
+ {this.state.currentTab==='notifications' && }
+
;
}
});
diff --git a/client/admin/admin.less b/client/admin/admin.less
index a61335835..c6c9b4662 100644
--- a/client/admin/admin.less
+++ b/client/admin/admin.less
@@ -6,39 +6,95 @@
@import 'font-awesome/css/font-awesome.css';
-html,body, #reactContainer, .naturalCrit{
- min-height : 100%;
-}
+html,body, #reactContainer, .naturalCrit { min-height : 100%; }
@sidebarWidth : 250px;
-body{
- background-color : #eee;
- font-family : 'Open Sans', sans-serif;
- color : #4b5055;
- font-weight : 100;
- text-rendering : optimizeLegibility;
- margin : 0;
+body {
+ height : 100%;
padding : 0;
- height : 100%;
+ margin : 0;
+ font-family : 'Open Sans', sans-serif;
+ font-weight : 100;
+ color : #4B5055;
+ background-color : #EEEEEE;
+ text-rendering : optimizeLegibility;
}
-.admin{
+:where(.admin) {
- header{
+ header {
+ padding : 20px 0px;
+ margin-bottom : 30px;
+ font-size : 2em;
+ color : white;
background-color : @red;
- font-size: 2em;
- padding : 20px 0px;
- color : white;
- margin-bottom: 30px;
- i{
- margin-right: 30px;
+ i { margin-right : 30px; }
+ }
+
+ hr { margin : 30px 0px; }
+
+ :where(.container) {
+ input {
+ height : 33px;
+ padding : 0px 10px;
+ margin-bottom : 20px;
+ font-family : monospace;
+ }
+
+ button {
+ height : 37px;
+ vertical-align : middle;
+ }
+
+ dl {
+ @maxItemWidth : 132px;
+ dt {
+ float : left;
+ width : @maxItemWidth;
+ clear : left;
+ text-align : right;
+ &::after { content : ' : '; }
+ }
+ dd {
+ height : 1em;
+ padding : 0 0 0.5em 0;
+ margin-left : @maxItemWidth + 6px;
+ }
+ }
+
+ .tabs button {
+ margin-right : 3px;
+ margin-left : 3px;
+ color : black;
+ background-color : #EEEEEE;
+ border : 1px solid #444444;
+ border-radius : 5px;
+ &:hover {
+ color : #EEEEEE;
+ background-color : #444444;
+ }
+ &.active {
+ margin-right : 2px;
+ margin-left : 2px;
+ text-decoration : underline;
+ background-color : #CCCCCC;
+ border : 2px solid #444444;
+ }
+ }
+
+ .notificationUtils {
+ display : flex;
+ gap : 50px;
+ justify-content : space-between;
}
}
- hr{
- margin : 30px 0px;
+ .error {
+ background: rgb(178, 54, 54);
+ color:white;
+ font-weight: 900;
+ margin-block:10px;
+ padding:10px;
}
-
-
}
diff --git a/client/admin/brewCleanup/brewCleanup.less b/client/admin/brewCleanup/brewCleanup.less
deleted file mode 100644
index ec7582855..000000000
--- a/client/admin/brewCleanup/brewCleanup.less
+++ /dev/null
@@ -1,10 +0,0 @@
-.BrewCleanup{
- .removeBox{
- margin-top: 20px;
- button{
- background-color: @red;
- margin-right: 10px;
- }
- }
-
-}
\ No newline at end of file
diff --git a/client/admin/brewCompress/brewCompress.less b/client/admin/brewCompress/brewCompress.less
deleted file mode 100644
index 2a2bf42ea..000000000
--- a/client/admin/brewCompress/brewCompress.less
+++ /dev/null
@@ -1,10 +0,0 @@
-.BrewCompress{
- .removeBox{
- margin-top: 20px;
- button{
- background-color: @red;
- margin-right: 10px;
- }
- }
-
-}
\ No newline at end of file
diff --git a/client/admin/brewLookup/brewLookup.less b/client/admin/brewLookup/brewLookup.less
deleted file mode 100644
index 61eeec424..000000000
--- a/client/admin/brewLookup/brewLookup.less
+++ /dev/null
@@ -1,30 +0,0 @@
-
-.brewLookup{
- input{
- height : 33px;
- margin-bottom : 20px;
- padding : 0px 10px;
- font-family : monospace;
- }
- button{
- vertical-align : middle;
- height : 37px;
- }
- dl{
- @maxItemWidth : 132px;
- dt{
- float : left;
- clear : left;
- width : @maxItemWidth;
- text-align : right;
- &::after {
- content: " : ";
- }
- }
- dd{
- height : 1em;
- margin-left : @maxItemWidth + 6px;
- padding : 0 0 0.5em 0;
- }
- }
-}
\ No newline at end of file
diff --git a/client/admin/brewCleanup/brewCleanup.jsx b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx
similarity index 100%
rename from client/admin/brewCleanup/brewCleanup.jsx
rename to client/admin/brewUtils/brewCleanup/brewCleanup.jsx
diff --git a/client/admin/brewUtils/brewCleanup/brewCleanup.less b/client/admin/brewUtils/brewCleanup/brewCleanup.less
new file mode 100644
index 000000000..16fc98957
--- /dev/null
+++ b/client/admin/brewUtils/brewCleanup/brewCleanup.less
@@ -0,0 +1,9 @@
+.BrewCleanup {
+ .removeBox {
+ margin-top : 20px;
+ button {
+ margin-right : 10px;
+ background-color : @red;
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/brewCompress/brewCompress.jsx b/client/admin/brewUtils/brewCompress/brewCompress.jsx
similarity index 100%
rename from client/admin/brewCompress/brewCompress.jsx
rename to client/admin/brewUtils/brewCompress/brewCompress.jsx
diff --git a/client/admin/brewUtils/brewCompress/brewCompress.less b/client/admin/brewUtils/brewCompress/brewCompress.less
new file mode 100644
index 000000000..8668e9280
--- /dev/null
+++ b/client/admin/brewUtils/brewCompress/brewCompress.less
@@ -0,0 +1,9 @@
+.BrewCompress {
+ .removeBox {
+ margin-top : 20px;
+ button {
+ margin-right : 10px;
+ background-color : @red;
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/brewLookup/brewLookup.jsx b/client/admin/brewUtils/brewLookup/brewLookup.jsx
similarity index 98%
rename from client/admin/brewLookup/brewLookup.jsx
rename to client/admin/brewUtils/brewLookup/brewLookup.jsx
index c9212d990..50a2f2015 100644
--- a/client/admin/brewLookup/brewLookup.jsx
+++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx
@@ -1,4 +1,3 @@
-require('./brewLookup.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
diff --git a/client/admin/brewUtils/brewUtils.jsx b/client/admin/brewUtils/brewUtils.jsx
new file mode 100644
index 000000000..de8c29895
--- /dev/null
+++ b/client/admin/brewUtils/brewUtils.jsx
@@ -0,0 +1,24 @@
+const React = require('react');
+const createClass = require('create-react-class');
+
+
+const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
+const BrewLookup = require('./brewLookup/brewLookup.jsx');
+const BrewCompress = require ('./brewCompress/brewCompress.jsx');
+const Stats = require('./stats/stats.jsx');
+
+const BrewUtils = createClass({
+ render : function(){
+ return <>
+
+
+
+
+
+
+
+ >;
+ }
+});
+
+module.exports = BrewUtils;
diff --git a/client/admin/stats/stats.jsx b/client/admin/brewUtils/stats/stats.jsx
similarity index 100%
rename from client/admin/stats/stats.jsx
rename to client/admin/brewUtils/stats/stats.jsx
diff --git a/client/admin/brewUtils/stats/stats.less b/client/admin/brewUtils/stats/stats.less
new file mode 100644
index 000000000..b5a4612e1
--- /dev/null
+++ b/client/admin/brewUtils/stats/stats.less
@@ -0,0 +1,13 @@
+
+.Stats {
+ position : relative;
+
+ .pending {
+ position : absolute;
+ top : 0px;
+ left : 0px;
+ width : 100%;
+ height : 100%;
+ background-color : rgba(238,238,238, 0.5);
+ }
+}
\ No newline at end of file
diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx
new file mode 100644
index 000000000..5a8ebf5d0
--- /dev/null
+++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx
@@ -0,0 +1,109 @@
+require('./notificationAdd.less');
+const React = require('react');
+const { useState, useRef } = require('react');
+const request = require('superagent');
+
+const NotificationAdd = ()=>{
+ const [notificationResult, setNotificationResult] = useState(null);
+ const [searching, setSearching] = useState(false);
+ const [error, setError] = useState(null);
+
+ const dismissKeyRef = useRef(null);
+ const titleRef = useRef(null);
+ const textRef = useRef(null);
+ const startAtRef = useRef(null);
+ const stopAtRef = useRef(null);
+
+ const saveNotification = async ()=>{
+ const dismissKey = dismissKeyRef.current.value;
+ const title = titleRef.current.value;
+ const text = textRef.current.value;
+ const startAt = new Date(startAtRef.current.value);
+ const stopAt = new Date(stopAtRef.current.value);
+
+ // Basic validation
+ if(!dismissKey || !title || !text || isNaN(startAt.getTime()) || isNaN(stopAt.getTime())) {
+ setError('All fields are required');
+ return;
+ }
+ if(startAt >= stopAt) {
+ setError('End date must be after the start date!');
+ return;
+ }
+
+ const data = {
+ dismissKey,
+ title,
+ text,
+ startAt : startAt?.toISOString() ?? '',
+ stopAt : stopAt?.toISOString() ?? '',
+ };
+
+ try {
+ setSearching(true);
+ setError(null);
+ const response = await request.post('/admin/notification/add').send(data);
+ console.log(response.body);
+
+ // Reset form fields
+ dismissKeyRef.current.value = '';
+ titleRef.current.value = '';
+ textRef.current.value = '';
+
+ setNotificationResult('Notification successfully created.');
+ setSearching(false);
+ } catch (err) {
+ console.log(err.response.body.message);
+ setError(`Error saving notification: ${err.response.body.message}`);
+ setSearching(false);
+ }
+ };
+
+ return (
+
+
Add Notification
+
+
+
+
+
+
+
+
+
+
+
+
{notificationResult}
+
+
+ {error &&
{error}
}
+
+ );
+};
+
+module.exports = NotificationAdd;
diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.less b/client/admin/notificationUtils/notificationAdd/notificationAdd.less
new file mode 100644
index 000000000..878da24c2
--- /dev/null
+++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.less
@@ -0,0 +1,37 @@
+.notificationAdd {
+ position : relative;
+ display : flex;
+ flex-direction : column;
+ width : 500px;
+
+ .field {
+ display : grid;
+ grid-template-columns : 120px 150px;
+ align-items : center;
+ justify-items : stretch;
+ width : 100%;
+ margin-bottom : 20px;
+
+
+ input {
+ height : 33px;
+ padding : 0px 10px;
+ margin-bottom : unset;
+ font-family : monospace;
+ }
+
+ textarea {
+ width : 50ch;
+ min-height : 7em;
+ max-height : 20em;
+ resize : vertical;
+ padding : 10px;
+ }
+ }
+
+ button {
+ width: 200px;
+
+ i { margin-right : 10px; }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx
new file mode 100644
index 000000000..71f8da59c
--- /dev/null
+++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx
@@ -0,0 +1,105 @@
+require('./notificationLookup.less');
+
+const React = require('react');
+const { useState } = require('react');
+const request = require('superagent');
+const Moment = require('moment');
+
+const NotificationDetail = ({ notification, onDelete })=>(
+ <>
+
+ - Key
+ - {notification.dismissKey}
+
+ - Title
+ - {notification.title || 'No Title'}
+
+ - Text
+ - {notification.text || 'No Text'}
+
+ - Created
+ - {Moment(notification.createdAt).format('LLLL')}
+
+ - Start
+ - {Moment(notification.startAt).format('LLLL') || 'No Start Time'}
+
+ - Stop
+ - {Moment(notification.stopAt).format('LLLL') || 'No End Time'}
+
+
+ >
+);
+
+const NotificationLookup = ()=>{
+ const [searching, setSearching] = useState(false);
+ const [error, setError] = useState(null);
+ const [notifications, setNotifications] = useState([]);
+
+ const lookupAll = async ()=>{
+ setSearching(true);
+ setError(null);
+
+ try {
+ const res = await request.get('/admin/notification/all');
+ setNotifications(res.body || []);
+ } catch (err) {
+ console.log(err);
+ setError(`Error looking up notifications: ${err.response.body.message}`);
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const deleteNotification = async (dismissKey)=>{
+ if(!dismissKey) return;
+
+ const confirmed = window.confirm(
+ `Really delete notification ${dismissKey}?`
+ );
+ if(!confirmed) {
+ console.log('Delete notification cancelled');
+ return;
+ }
+ console.log('Delete notification confirm');
+ try {
+ await request.delete(`/admin/notification/delete/${dismissKey}`);
+ lookupAll();
+ } catch (err) {
+ console.log(err);
+ setError(`Error deleting notification: ${err.response.body.message}`);
+ };
+ };
+
+ const renderNotificationsList = ()=>{
+ if(error)
+ return {error}
;
+
+ if(notifications.length === 0)
+ return No notifications available.
;
+
+ return (
+
+ {notifications.map((notification)=>(
+ -
+
+ {notification.title || 'No Title'}
+
+
+
+ ))}
+
+ );
+ };
+
+ return (
+
+
Check all Notifications
+
+ {renderNotificationsList()}
+
+ );
+};
+
+module.exports = NotificationLookup;
diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.less b/client/admin/notificationUtils/notificationLookup/notificationLookup.less
new file mode 100644
index 000000000..3f9b78310
--- /dev/null
+++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.less
@@ -0,0 +1,40 @@
+
+.notificationLookup {
+ width : 450px;
+ height : fit-content;
+
+ .notificationList {
+ display : flex;
+ flex-direction : column;
+ max-height : 500px;
+ margin-block : 20px;
+ overflow : auto;
+ border : 1px solid;
+ border-radius : 5px;
+
+ li {
+ padding : 10px;
+ background : #CCCCCC;
+
+ &:nth-child(even) { background : #DDDDDD; }
+ &:first-child {
+ border-top-left-radius : 5px;
+ border-top-right-radius : 5px;
+ }
+ &:last-child {
+ border-bottom-right-radius : 5px;
+ border-bottom-left-radius : 5px;
+ }
+
+ summary {
+ font-size : 20px;
+ font-weight : 900;
+ }
+
+ dl dt{
+ font-weight: 900;
+ }
+ }
+ }
+ .noNotification { margin-block : 20px; }
+}
\ No newline at end of file
diff --git a/client/admin/notificationUtils/notificationUtils.jsx b/client/admin/notificationUtils/notificationUtils.jsx
new file mode 100644
index 000000000..22ea21328
--- /dev/null
+++ b/client/admin/notificationUtils/notificationUtils.jsx
@@ -0,0 +1,15 @@
+const React = require('react');
+
+const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
+const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
+
+const NotificationUtils = ()=>{
+ return (
+
+ );
+};
+
+module.exports = NotificationUtils;
diff --git a/client/admin/stats/stats.less b/client/admin/stats/stats.less
deleted file mode 100644
index 5337bf671..000000000
--- a/client/admin/stats/stats.less
+++ /dev/null
@@ -1,28 +0,0 @@
-
-.Stats{
- position : relative;
- .pending{
- position : absolute;
- top : 0px;
- left : 0px;
- height : 100%;
- width : 100%;
- background-color : rgba(238,238,238, 0.5);
- }
- dl{
- @maxItemWidth : 132px;
- dt{
- float : left;
- clear : left;
- width : @maxItemWidth;
- text-align : right;
- &::after {
- content: " : ";
- }
- }
- dd{
- margin : 0 0 0 @maxItemWidth + 10px;
- padding : 0 0 0.5em 0;
- }
- }
-}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 7d296cd69..58a6b9a64 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,12 +18,12 @@
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
- "cookie-parser": "^1.4.6",
+ "cookie-parser": "^1.4.7",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.7",
"expr-eval": "^2.0.2",
- "express": "^4.21.0",
+ "express": "^4.21.1",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
@@ -4817,21 +4817,19 @@
"license": "MIT"
},
"node_modules/cookie": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
- "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
- "license": "MIT",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
- "version": "1.4.6",
- "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
- "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
- "license": "MIT",
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"dependencies": {
- "cookie": "0.4.1",
+ "cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
@@ -6255,16 +6253,16 @@
"license": "MIT"
},
"node_modules/express": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
- "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
+ "version": "4.21.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
+ "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
- "cookie": "0.6.0",
+ "cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -6310,10 +6308,9 @@
}
},
"node_modules/express/node_modules/cookie": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
- "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
- "license": "MIT",
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
diff --git a/package.json b/package.json
index 3199f474d..af3d5071d 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
+ "test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -93,12 +94,12 @@
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
- "cookie-parser": "^1.4.6",
+ "cookie-parser": "^1.4.7",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.7",
"expr-eval": "^2.0.2",
- "express": "^4.21.0",
+ "express": "^4.21.1",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
diff --git a/server/admin.api.js b/server/admin.api.js
index fe2def3ce..a112dc6f1 100644
--- a/server/admin.api.js
+++ b/server/admin.api.js
@@ -1,4 +1,5 @@
const HomebrewModel = require('./homebrew.model.js').model;
+const NotificationModel = require('./notifications.model.js').model;
const router = require('express').Router();
const Moment = require('moment');
//const render = require('vitreum/steps/render');
@@ -138,12 +139,48 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
}
});
+// ####################### NOTIFICATIONS
+
+router.get('/admin/notification/all', async (req, res, next)=>{
+ try {
+ const notifications = await NotificationModel.getAll();
+ return res.json(notifications);
+ } catch (error) {
+ console.log('Error getting all notifications: ', error.message);
+ return res.status(500).json({ message: error.message });
+ }
+});
+
+router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
+ console.table(req.body);
+ try {
+ const notification = await NotificationModel.addNotification(req.body);
+ return res.status(201).json(notification);
+ } catch (error) {
+ console.log('Error adding notification: ', error.message);
+ return res.status(500).json({ message: error.message });
+ }
+});
+
+router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
+ try {
+ const notification = await NotificationModel.deleteNotification(req.params.id);
+ return res.json(notification);
+ } catch (error) {
+ console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
+ return res.status(500).json({ message: error.message });
+ }
+});
+
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
url : req.originalUrl
})
.then((page)=>res.send(page))
- .catch((err)=>res.sendStatus(500));
+ .catch((err)=>{
+ console.log(err);
+ res.sendStatus(500);
+ });
});
module.exports = router;
diff --git a/server/admin.api.spec.js b/server/admin.api.spec.js
new file mode 100644
index 000000000..b0dbd5d84
--- /dev/null
+++ b/server/admin.api.spec.js
@@ -0,0 +1,116 @@
+const supertest = require('supertest');
+
+const app = supertest.agent(require('app.js').app)
+ .set('X-Forwarded-Proto', 'https');
+
+const NotificationModel = require('./notifications.model.js').model;
+
+describe('Tests for admin api', ()=>{
+ afterEach(()=>{
+ jest.resetAllMocks();
+ });
+
+ describe('Notifications', ()=>{
+ it('should return list of all notifications', async ()=>{
+ const testNotifications = ['a', 'b'];
+
+ jest.spyOn(NotificationModel, 'find')
+ .mockImplementationOnce(() => {
+ return { exec: jest.fn().mockResolvedValue(testNotifications) };
+ });
+
+ const response = await app
+ .get('/admin/notification/all')
+ .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(testNotifications);
+ });
+
+ it('should add a new notification', async ()=>{
+ const inputNotification = {
+ title : 'Test Notification',
+ text : 'This is a test notification',
+ startAt : new Date().toISOString(),
+ stopAt : new Date().toISOString(),
+ dismissKey : 'testKey'
+ };
+
+ const savedNotification = {
+ ...inputNotification,
+ _id : expect.any(String),
+ createdAt : expect.any(String),
+ startAt : inputNotification.startAt,
+ stopAt : inputNotification.stopAt,
+ };
+
+ jest.spyOn(NotificationModel.prototype, 'save')
+ .mockImplementationOnce(function() {
+ return Promise.resolve(this);
+ });
+
+ const response = await app
+ .post('/admin/notification/add')
+ .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
+ .send(inputNotification);
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(savedNotification);
+ });
+
+ it('should handle error adding a notification without dismissKey', async () => {
+ const inputNotification = {
+ title : 'Test Notification',
+ text : 'This is a test notification',
+ startAt : new Date().toISOString(),
+ stopAt : new Date().toISOString()
+ };
+
+ //Change 'save' function to just return itself instead of actually interacting with the database
+ jest.spyOn(NotificationModel.prototype, 'save')
+ .mockImplementationOnce(function() {
+ return Promise.resolve(this);
+ });
+
+ const response = await app
+ .post('/admin/notification/add')
+ .set('Authorization', 'Basic ' + Buffer.from('admin:password3').toString('base64'))
+ .send(inputNotification);
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Dismiss key is required!' });
+ });
+
+ it('should delete a notification based on its dismiss key', async ()=>{
+ const dismissKey = 'testKey';
+
+ jest.spyOn(NotificationModel, 'findOneAndDelete')
+ .mockImplementationOnce((key) => {
+ return { exec: jest.fn().mockResolvedValue(key) };
+ });
+ const response = await app
+ .delete(`/admin/notification/delete/${dismissKey}`)
+ .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
+
+ expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ dismissKey: 'testKey' });
+ });
+
+ it('should handle error deleting a notification that doesnt exist', async ()=>{
+ const dismissKey = 'testKey';
+
+ jest.spyOn(NotificationModel, 'findOneAndDelete')
+ .mockImplementationOnce(() => {
+ return { exec: jest.fn().mockResolvedValue() };
+ });
+ const response = await app
+ .delete(`/admin/notification/delete/${dismissKey}`)
+ .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
+
+ expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Notification not found' });
+ });
+ });
+});
diff --git a/server/notifications.model.js b/server/notifications.model.js
new file mode 100644
index 000000000..0a32bde8a
--- /dev/null
+++ b/server/notifications.model.js
@@ -0,0 +1,62 @@
+const mongoose = require('mongoose');
+const _ = require('lodash');
+
+const NotificationSchema = new mongoose.Schema({
+ dismissKey : { type: String, unique: true, required: true },
+ title : { type: String, default: '' },
+ text : { type: String, default: '' },
+ createdAt : { type: Date, default: Date.now },
+ startAt : { type: Date, default: Date.now },
+ stopAt : { type: Date, default: Date.now },
+}, { versionKey: false });
+
+NotificationSchema.statics.addNotification = async function(data) {
+ if(!data.dismissKey) throw { message: 'Dismiss key is required!' };
+
+ const defaults = {
+ title : '',
+ text : '',
+ startAt : new Date(),
+ stopAt : new Date(),
+ };
+
+ const notificationData = _.defaults(data, defaults);
+
+ try {
+ const newNotification = new this(notificationData);
+ const savedNotification = await newNotification.save();
+ return savedNotification;
+ } catch (err) {
+ throw { message: err.message || 'Error saving notification' };
+ }
+};
+
+NotificationSchema.statics.deleteNotification = async function(dismissKey) {
+ if(!dismissKey) throw { message: 'Dismiss key is required!' };
+
+ try {
+ const deletedNotification = await this.findOneAndDelete({ dismissKey }).exec();
+ if(!deletedNotification) {
+ throw { message: 'Notification not found' };
+ }
+ return deletedNotification;
+ } catch (err) {
+ throw { message: err.message || 'Error deleting notification' };
+ }
+};
+
+NotificationSchema.statics.getAll = async function() {
+ try {
+ const notifications = await this.find().exec();
+ return notifications;
+ } catch (err) {
+ throw { message: err.message || 'Error retrieving notifications' };
+ }
+};
+
+const Notification = mongoose.model('Notification', NotificationSchema);
+
+module.exports = {
+ schema : NotificationSchema,
+ model : Notification,
+};