diff --git a/.vscode/settings.json b/.vscode/settings.json index e75b1e7..159f91b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "cSpell.language": "en,de-DE" + "cSpell.language": "en,de-DE", + "cSpell.words": [ + "repotted" + ] } \ No newline at end of file diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index 6e7692a..420ceae 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -2,6 +2,11 @@ const helper = require('../helper.js'); const daoHelper = require('./daoHelper.js'); +// TODO Add JSDoc comments to all methods +// TODO Make date handling consistent (either use strings or DateTime objects throughout the DAO) +// Post uses DateTime objects, Put uses strings +// Strings is probably better because the validation middleware uses strings +// TODO change var to const/let /** * Data Access Object for plants diff --git a/backend/helper.js b/backend/helper.js index 8ce68e3..fc56d4f 100644 --- a/backend/helper.js +++ b/backend/helper.js @@ -202,6 +202,18 @@ module.exports.compareDateTimes = function(leftdatetime, rightdatetime) { return 0; } +/** + * Probably not correctly implemented, see issue #39 + * Calculates the number of days between two datetime objects + * @param {*} leftdatetime The left datetime object + * @param {*} rightdatetime The right datetime object + * @returns The number of days between the two datetime objects + */ +module.exports.calculateDaysBetween = function(leftdatetime, rightdatetime) { + const timeDifference = Math.abs(leftdatetime - rightdatetime); + return Math.floor(timeDifference / (1000 * 60 * 60 * 24)); +} + // modifies a given datetime object // adds or subs values to years, months, days, hours, minutes, seconds // positive values are added, negative ones subbed. 0 values are ignored diff --git a/backend/package-lock.json b/backend/package-lock.json index 4d622c8..ce76081 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "plant_manager_backend", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plant_manager_backend", - "version": "1.1.2", + "version": "1.1.3", "license": "ISC", "dependencies": { "better-sqlite3": "^11.0.0", @@ -14,6 +14,7 @@ "cors": "^2.8.5", "express": "^4.19.2", "express-fileupload": "^1.5.0", + "express-validator": "^7.3.0", "luxon": "^3.4.4", "morgan": "^1.10.0" }, @@ -1176,6 +1177,19 @@ "node": ">=12.0.0" } }, + "node_modules/express-validator": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz", + "integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.15" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1615,9 +1629,9 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1688,6 +1702,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2748,6 +2768,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index e2fa306..dc40b21 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "plant_manager_backend", - "version": "1.1.2", + "version": "1.1.3", "description": "Backend for PlantManager with file upload and sqlite", "main": "server.js", "scripts": { @@ -16,6 +16,7 @@ "cors": "^2.8.5", "express": "^4.19.2", "express-fileupload": "^1.5.0", + "express-validator": "^7.3.0", "luxon": "^3.4.4", "morgan": "^1.10.0" }, diff --git a/backend/server.js b/backend/server.js index 165965e..f6c95f5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,7 +24,11 @@ try // connect database console.log('Connect database...'); const Database = require('better-sqlite3'); - const dbOptions = { verbose: console.log }; + let dbOptions = {} + if (process.env.LOG_SQLITE_QUERIES === 'true') { + console.log('Database debugging enabled'); + dbOptions = { verbose: console.log }; + } const dbFile = './db/plantmanagerDB.sqlite'; const dbConnection = new Database(dbFile, dbOptions); @@ -67,7 +71,11 @@ try response.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); }); - app.use(morgan('dev')); + + // log all requests to the console + if (process.env.LOG_HTTP_QUERIES === 'true') { + app.use(morgan('dev')); + } // ====== BINDING ENDPOINTS ====== const TOPLEVELPATH = '/api'; @@ -85,8 +93,8 @@ try // send default error message if no matching endpoint found app.use(function (request, response) { - console.log('Error occured, 404, resource not found'); - response.status(404).json({'fehler': true, 'nachricht': 'Resource nicht gefunden'}); + console.log('Error occurred, 404, resource not found'); + response.status(404).json({ errors: [{ 'msg': "API Endpoint not found"}] }); }); // =============================== @@ -97,9 +105,9 @@ try console.log('Listening at localhost, port ' + HTTP_PORT); console.log('\nUsage: http://localhost:' + HTTP_PORT + TOPLEVELPATH + "/SERVICENAME/SERVICEMETHOD/...."); console.log('\nPlant Manager Backend \nDeveloped by: QuadcoreDevelopment'); - console.log('\n\n-----------------------------------------'); - console.log('exit / stop Server by pressing 2 x CTRL-C'); - console.log('-----------------------------------------\n\n'); + console.log('\n\n-------------------------------------'); + console.log('exit / stop Server by pressing CTRL-C'); + console.log('-------------------------------------\n\n'); }); } catch (ex) diff --git a/backend/services/activities.js b/backend/services/activities.js index c6562f3..ff6c154 100644 --- a/backend/services/activities.js +++ b/backend/services/activities.js @@ -1,119 +1,105 @@ const helper = require('../helper.js'); +const validationHelper = require('./validationHelper.js'); const activitiesDao = require('../dao/activitiesDao.js'); -const plantsDao = require('../dao/plantsDao.js'); const express = require('express'); -var serviceRouter = express.Router(); +let serviceRouter = express.Router(); +const { body, param, matchedData, validationResult } = require('express-validator'); console.log('- Service Activities'); -// Neue Activity erstellen -serviceRouter.post('/activities', function(request, response) { - console.log('Activities plants: Client requested creation of new record'); - - var errorMsgs=[]; - if (helper.isUndefined(request.body.plant_id)) { - errorMsgs.push('plant_id missing'); - } - if (request.body.plant_id < 0) { - errorMsgs.push('plant_id cannot be negative'); - } - // checks if plant_id starts with a number but is a string - if(helper.strHasNumericValue(request.body.plant_id)) { - request.body.plant_id = parseInt(request.body.plant_id, 10); - } - if (helper.isNull(request.body.plant_id)) { - errorMsgs.push('plant_id is null'); - } - if (helper.isUndefined(request.body.type)) { - errorMsgs.push('type missing'); - } - if (request.body.type < 0 || request.body.type > 1) { - errorMsgs.push('type cannot be negative or greater than 1'); - } - if(helper.strHasNumericValue(request.body.type)) { - request.body.type = parseInt(request.body.type, 10); - } - if (helper.isNull(request.body.type)) { - errorMsgs.push('type is null'); - } - // Aktuelles Datum für date nehmen - if (helper.isUndefined(request.body.date)) { - request.body.date = helper.getNow(); - } else { - // Date wenn es ein String ist in ein valides date Object umwandeln - try { - if(helper.isString(request.body.date)) { - request.body.date = helper.parseDateTimeString(request.body.date); - } - } catch (ex) { - errorMsgs.push('DateString could not be transformed into Date Object' + ex); +/** + * Adds the days_since field to each activity in the provided array. + * @param {*} activities The array of activity objects to process + */ +function addDaysSinceToActivities(activities) { + const currentDate = new Date(); + activities.forEach(activity => { + if (activity && activity.date) { + const activityDate = new Date(activity.date); + const daysSince = helper.calculateDaysBetween(activityDate, currentDate); + activity.days_since = daysSince; } + }); +} + +// Create new activity +serviceRouter.post('/activities', + body("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + body("type").isInt({min:0, max:1}).toInt(), + body("date").optional().isISO8601(), + function(req, resp) { + + console.log('Service activities: Client requested creation of new activity'); + const result = validationResult(req); + if (!result.isEmpty()) { + console.warn('Service activities: Creation not possible, validation errors'); + return resp.status(400).json({ errors: result.array() }); } - - // Typüberprüfung der Werte - if(!helper.isNumeric(request.body.plant_id)) { - errorMsgs.push('plant_id is not a numeric value.'); - } - if(!helper.isNumeric(request.body.type)) { - errorMsgs.push('type is not a numeric value.'); - } - if(!helper.isDateTime(request.body.date)) { - errorMsgs.push('date is not a valid dateTime format.'); + + const data = matchedData(req); + // use current date if date is not provided + if (helper.isUndefined(data.date)) { + data.date = helper.getNow(); } - - if (errorMsgs.length > 0) { - console.log('Service activities: Creation not possible, data missing'); - response.status(400).json({ 'fehler': true, 'nachricht': 'Function not possible. Missing Data: ' + helper.concatArray(errorMsgs) }); - return; + else { + data.date = helper.parseDateTimeString(data.date); } - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); - const plantsDaoInstance = new plantsDao(request.app.locals.dbConnection); - + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { - // Check if plant with given plant_id actually exists - if (plantsDaoInstance.exists(request.body.plant_id)) { - var obj = activitiesDaoInstance.create(request.body.plant_id, request.body.type, request.body.date); - console.log('Service activities: Record inserted'); - response.status(200).json(obj); - } else { - console.error('Service activities: Plant with given ID does not exist.'); - response.status(404).json({ 'fehler': true, 'nachricht': 'Plant with the given ID does not exist.' }); - } + let obj = activitiesDaoInstance.create(data.plant_id, data.type, data.date); + console.log('Service activities: Record inserted'); + resp.status(200).json(obj); } catch (ex) { console.error('Service activities: Error creating new record. Exception occurred: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -// Activity nach ID holen -serviceRouter.get('/activities/exists/:id', function(request, response) { - console.log('Service activities: Client requested check, if activity exists, id=' + request.params.id); - - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); +// Activity exists check +serviceRouter.get('/activities/exists/:id', + param("id").isInt({min:0}).toInt(), + function(req, resp) { + + console.log('Service activities: Client requested check, if activity exists'); + const result = validationResult(req); + if (!result.isEmpty()) { + console.warn('Service activities: Check not possible, validation errors'); + return resp.status(400).json({ errors: result.array() }); + } + + const data = matchedData(req); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { - var exists = activitiesDaoInstance.exists(request.params.id); - console.log('Service activities: Check if activity exists by id=' + request.params.id +', exists= ' + exists); - response.status(200).json({'id': parseInt(request.params.id), 'existiert': exists}); + let exists = activitiesDaoInstance.exists(data.id); + console.log('Service activities: Check if activity exists by id=' + data.id +', exists= ' + exists); + resp.status(200).json({'id': data.id, 'exists': exists}); } catch (ex) { console.error('Service activities: Error checking if record exists. Exception occurred: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -// Alle Activities für Plant_ID holen -serviceRouter.get('/activities/all/:plant_id', function(request, response) { +// Get all activities for a plants +serviceRouter.get('/activities/all/:plant_id', + param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + function(req, resp) { - console.log('Service activities: Client requested all records'); + console.log('Service activities: Client requested all records for a plant'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service activities: Fetch not possible, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); + const data = matchedData(req); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); - request.body.days_since = request.body.date - helper.getNow(); try { - var result = activitiesDaoInstance.loadByPlantId(request.params.plant_id); - console.log('Service activities: Records loaded, result= ', result); + let result = activitiesDaoInstance.loadByPlantId(data.plant_id); + console.log('Service activities: Records loaded for plant_id = ' + data.plant_id); - var activities = []; + let activities = []; // Check if result is an array or a single object if (Array.isArray(result)) { @@ -121,45 +107,41 @@ serviceRouter.get('/activities/all/:plant_id', function(request, response) { } else if (result && typeof result === 'object') { activities = [result]; } else { - return response.status(404).json({ 'fehler': true, 'nachricht': 'No activities found for the given plant ID.' }); + return resp.status(404).json({ errors: [{msg: 'No activities found for the given plant ID.'}] }); } - + // Process each activity - const currentDate = new Date(); - activities.forEach(activity => { - if (activity && activity.date) { - const activityDate = new Date(activity.date); - const timeDifference = currentDate - activityDate; - const daysSince = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - activity.days_since = daysSince; - } - }); + addDaysSinceToActivities(activities); - response.status(200).json(activities); + resp.status(200).json(activities); } catch (ex) { - console.error('Service activities: Error loading all records. Exception occurred: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service activities: Error loading all records based on plant_id. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -// Activity löschen -serviceRouter.delete('/activities/:id', function(request, response) { - console.log('Service activities: Client requested deletion of activity, id=' + request.params.id); +// delete activity by id +serviceRouter.delete('/activities/:id', + param("id").isInt({min:0}).bail().toInt().custom(validationHelper.validateActivityIDExists), + function(req, resp) { + + console.log('Service activities: Client requested deletion of activity'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service activities: Deletion not possible, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); + const data = matchedData(req); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { - if (activitiesDaoInstance.exists(request.params.id)) { - activitiesDaoInstance.delete(request.params.id); - console.log('Service activities: Deletion of activity successfull, id=' + request.params.id); - response.status(200).json({ 'fehler': false, 'nachricht': 'Activity deleted' }); - } else { - console.error('Service activities: Activity with given ID does not exist.'); - response.status(404).json({ 'fehler': true, 'nachricht': 'Activity with the given ID does not exist.' }); - } + activitiesDaoInstance.delete(data.id); + console.log('Service activities: Deletion of activity successful, id=' + data.id); + resp.status(200).json({'id': data.id, 'deleted': true}); } catch (ex) { console.error('Service activities: Error deleting record. Exception occurred: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); diff --git a/backend/services/plants.js b/backend/services/plants.js index 5bf0686..2eed831 100644 --- a/backend/services/plants.js +++ b/backend/services/plants.js @@ -1,8 +1,10 @@ const helper = require('../helper.js'); +const validationHelper = require('./validationHelper.js'); const express = require('express'); const plantsDao = require('../dao/plantsDao.js'); const activitiesDao = require('../dao/activitiesDao.js'); var serviceRouter = express.Router(); +const { body, param, matchedData, validationResult } = require('express-validator'); console.log('- Service Plants'); @@ -13,8 +15,8 @@ function extendPlantJSON(json,activitiesDaoInstance) { // Berechnung watering_interval_calculated let watering_interval_calculated = json.watering_interval + json.watering_interval_offset; - // Datum letestes Bewässern ermitteln - var arrWat = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,0); + // Datum letztes Bewässern ermitteln + let arrWat = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,0); let last_watered = null; if(helper.isArray(arrWat)) { @@ -25,7 +27,7 @@ function extendPlantJSON(json,activitiesDaoInstance) { } else { - // 2 or more elemts = array + // 2 or more elements = array last_watered = new Date(arrWat[0].date); } } @@ -53,7 +55,7 @@ function extendPlantJSON(json,activitiesDaoInstance) { let days_until_watering = Math.floor(ms_until_watering / (1000 * 60 * 60 * 24)) +1; //Bestimmung repotted - var arrPot = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,1); + let arrPot = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,1); let repotted = null; if(helper.isArray(arrPot)) { @@ -64,7 +66,7 @@ function extendPlantJSON(json,activitiesDaoInstance) { } else { - // 2 or more elemts = array + // 2 or more elements = array repotted = arrPot[0].date; } } @@ -85,30 +87,39 @@ function extendPlantJSON(json,activitiesDaoInstance) { json.repotted = repotted; } -serviceRouter.get('/plants/get/:id', function(request, response) { - console.log('Service plants: Client requested one record, id=' + request.params.id); +serviceRouter.get('/plants/get/:plant_id', + param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + function(req, resp) { - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); + console.log('Service plants: Client requested one record'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error loading record by id, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } + + const data = matchedData(req); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { // JSON Objekt aus DB holen - var obj = plantDaoInstance.loadById(request.params.id); + var obj = plantDaoInstance.loadById(data.plant_id); extendPlantJSON(obj,activitiesDaoInstance); console.log('Service plants: Record loaded'); - response.status(200).json(obj); + resp.status(200).json(obj); } catch (ex) { - console.error('Service plants: Error loading record by id. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error loading record by id. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -serviceRouter.get('/plants/composted', function(request, response) { - console.log('Service plants: Client requested all records'); +serviceRouter.get('/plants/composted', function(req, resp) { + console.log('Service plants: Client requested all composted records'); - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { var plantArr = plantDaoInstance.loadAllComposted(); // foreach Schleife über alle plant JSON, diese werden dabei erweitert @@ -116,19 +127,19 @@ serviceRouter.get('/plants/composted', function(request, response) { extendPlantJSON(plant,activitiesDaoInstance); }); - console.log('Service plants: Records loaded, count= ' + plantArr.length); - response.status(200).json(plantArr); + console.log('Service plants: Composted records loaded, count= ' + plantArr.length); + resp.status(200).json(plantArr); } catch (ex) { - console.error('Service plants: Error loading all records. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error loading all composted records. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -serviceRouter.get('/plants/all', function(request, response) { +serviceRouter.get('/plants/all', function(req, resp) { console.log('Service plants: Client requested all records'); - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { var plantArr = plantDaoInstance.loadAll(); // foreach Schleife über alle plant JSON, diese werden dabei erweitert @@ -137,146 +148,163 @@ serviceRouter.get('/plants/all', function(request, response) { }); console.log('Service plants: Records loaded, count= ' + plantArr.length); - response.status(200).json(plantArr); + resp.status(200).json(plantArr); } catch (ex) { - console.error('Service plants: Error loading all records. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error loading all records. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -serviceRouter.get('/plants/exists/:id', function(request, response) { - console.log('Service plants: Client requested check, if record exists, id=' + request.params.id); +serviceRouter.get('/plants/exists/:plant_id', + param("plant_id").isInt({min:0}).toInt(), + function(req, resp) { - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); + console.log('Service plants: Client requested check, if record exists'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error checking if plant exists, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } + + const data = matchedData(req); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); try { - var exists = plantDaoInstance.exists(request.params.id); - console.log('Service plants: Check if record exists by id=' + request.params.id +', exists= ' + exists); - response.status(200).json({'plant_id': request.params.id, 'existiert': exists}); + var exists = plantDaoInstance.exists(data.plant_id); + console.log('Service plants: Check if record exists by id=' + data.plant_id +', exists= ' + exists); + resp.status(200).json({'plant_id': data.plant_id, 'exists': exists}); } catch (ex) { - console.error('Service plants: Error checking if record exists. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error checking if record exists. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -serviceRouter.post('/plants', function(request, response) { - console.log('Service plants: Client requested creation of new record'); +serviceRouter.post('/plants', + body("name").default("New Plant").isString().notEmpty().trim().escape(), + body("species_name").default("Unknown").isString().notEmpty().trim().escape(), + body("watering_interval").isInt({min:1,max:100}).toInt(), + body("watering_interval_offset").isInt({min:-25,max:25}).toInt(), + body("added").optional().isISO8601(), + function(req, resp) { - var errorMsgs=[]; - if (helper.isUndefined(request.body.name)) { - errorMsgs.push('name missing'); - } - if (helper.isUndefined(request.body.species_name)) { - errorMsgs.push('species_name missing'); - } - // Nimmt aktuelles Datum für added, kann noch anders strukturiert werden - if (helper.isUndefined(request.body.added)) { - request.body.added = helper.getNow(); - } - if (helper.isUndefined(request.body.watering_interval)) { - errorMsgs.push('watering_interval missing'); - } else if (!helper.isNumeric(request.body.watering_interval)) { - errorMsgs.push('watering_interval has to be a number'); - } else if (request.body.watering_interval <= 0) { - errorMsgs.push('watering_interval has to be a bigger number than 0'); + console.log('Service plants: Client requested creation of new record'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error creating new record, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); } - if (helper.isUndefined(request.body.watering_interval_offset)) { - errorMsgs.push('watering_interval_offset missing'); - } else if (!helper.isNumeric(request.body.watering_interval_offset)) { - errorMsgs.push('watering_interval has to be a number'); + const data = matchedData(req); + + // use current date if date is not provided + if (helper.isUndefined(data.added)) { + data.added = helper.getNow(); } - if (errorMsgs.length > 0) { - console.log('Service plants: Creation not possible, data missing'); - response.status(400).json({ 'fehler': true, 'nachricht': 'Function not possible. Missing Data: ' + helper.concatArray(errorMsgs) }); - return; + else { + data.added = helper.parseDateTimeString(data.added); } - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); try { // #37 image is set to null - var obj = plantDaoInstance.create(request.body.name, request.body.species_name,null,request.body.added,request.body.watering_interval,request.body.watering_interval_offset); + var obj = plantDaoInstance.create(data.name, data.species_name,null,data.added,data.watering_interval,data.watering_interval_offset); console.log('Service plants: Record inserted'); - response.status(200).json(obj); + resp.status(200).json(obj); } catch (ex) { - console.error('Service plants: Error creating new record. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); - } + console.error('Service plants: Error creating new record. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); + } }); -serviceRouter.put('/plants', function(request, response) { +serviceRouter.put('/plants', + body("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + body("name").optional().isString().notEmpty().trim().escape(), + body("species_name").optional().isString().notEmpty().trim().escape(), + body("watering_interval").optional().isInt({min:1,max:100}).toInt(), + body("watering_interval_offset").optional().isInt({min:-25,max:25}).toInt(), + body("composted").optional({values: "null"}).isISO8601(), + function(req, resp) { + + // Evaluate request console.log('Service plants: Client requested update of existing plant'); - - // TODO Replace this madness with a validation Framework like express validator - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - var errorMsgs=[]; - if (helper.isUndefined(request.body.plant_id)) { - errorMsgs.push('plant_id missing'); - } else if (!helper.isNumeric(request.body.plant_id)) { - errorMsgs.push('plant_id has to be a number'); - } else if (request.body.plant_id <= 0) { - errorMsgs.push('plant_id has to be a bigger number than 0'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error updating record, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); } + const data = matchedData(req); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); - if (helper.isUndefined(request.body.name)) { - errorMsgs.push('name missing'); + // Load existing plant data + let oldPlantData; + try { + oldPlantData = plantDaoInstance.loadById(data.plant_id) + console.log('Service plants: Loaded existing plant data for update, plant_id=' + data.plant_id); + } catch (ex) { + console.error('Service plants: Error loading old plant data. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); + } + + // Check which fields need to be updated + if (helper.isUndefined(data.name)) { + // use current name from DB + data.name = oldPlantData.name; } - - if (helper.isUndefined(request.body.species_name)) { - errorMsgs.push('species_name missing'); + if (helper.isUndefined(data.species_name)) { + // use current species_name from DB + data.species_name = oldPlantData.species_name; } - - if (helper.isUndefined(request.body.watering_interval)) { - errorMsgs.push('watering_interval missing'); - } else if (!helper.isNumeric(request.body.watering_interval)) { - errorMsgs.push('watering_interval has to be a number'); - } else if (request.body.watering_interval <= 0) { - errorMsgs.push('watering_interval has to be a number bigger than 0'); + if (helper.isUndefined(data.watering_interval)) { + // use current watering_interval from DB + data.watering_interval = oldPlantData.watering_interval; } - - if (helper.isUndefined(request.body.watering_interval_offset)) { - errorMsgs.push('watering_interval_offset missing'); - } else if (!helper.isNumeric(request.body.watering_interval_offset)) { - errorMsgs.push('watering_interval has to be a number'); + if (helper.isUndefined(data.watering_interval_offset)) { + // use current watering_interval_offset from DB + data.watering_interval_offset = oldPlantData.watering_interval_offset; } - if (errorMsgs.length > 0) { - console.log('Service plants: Update not possible, data missing or invalid'); - response.status(400).json({ 'fehler': true, 'nachricht': 'Function not possible. Missing or invalid data: ' + helper.concatArray(errorMsgs) }); - return; + // null is not passed by express-validator, so we need to check the original req.body + if (helper.isNull(req.body.composted)) { + // set composted to null + data.composted = null; + } + else if (helper.isUndefined(data.composted)) { + // use current composted from DB + data.composted = oldPlantData.composted; } - try { - // check if the plant even exists - if(!plantDaoInstance.exists(request.body.plant_id)) - { - console.error('Service plants: Error updating record by plant_id. No plant with this plant_id found'); - response.status(400).json({ 'fehler': true, 'nachricht': 'No plant with this plant_id found' }); - return; - } - - // get the current image of the plant as it should not be changed here. See issue #37 - let image = plantDaoInstance.loadById(request.body.plant_id).image; + // get the current image of the plant as it should not be changed here. See issue #37 + data.image = oldPlantData.image; + try { // update the plant - var obj = plantDaoInstance.update(request.body.plant_id,request.body.name, request.body.species_name,image,request.body.watering_interval,request.body.watering_interval_offset,request.body.composted); - console.log('Service plants: Record updated, plant_id=' + request.body.plant_id); - response.status(200).json(obj); + let obj = plantDaoInstance.update(data.plant_id, data.name, data.species_name, data.image, data.watering_interval, data.watering_interval_offset, data.composted); + console.log('Service plants: Record updated, plant_id=' + data.plant_id); + resp.status(200).json(obj); } catch (ex) { - console.error('Service plants: Error updating record by plant_id. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error updating record by plant_id. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); -serviceRouter.delete('/plants/:id', function(request, response) { - console.log('Service plants: Client requested deletion of plant, plant_id=' + request.params.id); +serviceRouter.delete('/plants/:plant_id', + param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + function(req, resp) { + + console.log('Service plants: Client requested deletion of plant'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error deleting plant, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } + const data = matchedData(req); - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); try { - plantDaoInstance.delete(request.params.id); - console.log('Service plants: Deletion of plant successfull, plant_id=' + request.params.id); - response.status(200).json({ 'fehler': false, 'nachricht': 'Plant deleted' }); + plantDaoInstance.delete(data.plant_id); + console.log('Service plants: Deletion of plant successful, plant_id=' + data.plant_id); + resp.status(200).json({'plant_id': data.plant_id, 'deleted': true}); } catch (ex) { - console.error('Service plants: Error deleting record. Exception occured: ' + ex.message); - response.status(400).json({ 'fehler': true, 'nachricht': ex.message }); + console.error('Service plants: Error deleting record. Exception occurred: ' + ex.message); + resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } }); diff --git a/backend/services/upload.js b/backend/services/upload.js index 7d229e1..2c3d76a 100644 --- a/backend/services/upload.js +++ b/backend/services/upload.js @@ -1,8 +1,10 @@ const helper = require('../helper.js'); +const validationHelper = require('./validationHelper.js'); const express = require('express'); const plantsDao = require('../dao/plantsDao.js'); const fs = require('fs'); var serviceRouter = express.Router(); +const { body, matchedData, validationResult } = require('express-validator'); console.log('- Service Upload'); @@ -18,67 +20,73 @@ function deletePublicImage(filenameAndPath){ }); } -serviceRouter.put('/upload/image', (request, response) => { +function isValidImageExtension(extension){ + const validExtensions = ['jpg','jpeg','png','gif','bmp','webp','heic']; + return validExtensions.includes(extension.toLowerCase()); +} + +function isValidImageMimeType(mimeType){ + // Based on tests I saw: 'image/jpeg','application/pdf' and 'application/x-msdownload' + const validMimeTypes = ['image/jpg','image/jpeg', 'image/heic','image/png','image/gif','image/bmp','image/webp']; + return validMimeTypes.includes(mimeType); +} + +// Files can't be validated with express-validator directly, so we only validate the plant_id here +serviceRouter.put('/upload/image', + body("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), + (req, resp) => { + console.log('Service Upload: Client uploaded an image'); + const vResult = validationResult(req); + if (!vResult.isEmpty()) { + console.warn('Service plants: Error uploading an image, validation errors'); + return resp.status(400).json({ errors: vResult.array() }); + } - const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - let errorMsgs=[]; - let plant_id = request.body.plant_id; + const plant_id = matchedData(req).plant_id; + const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); + let picture = null; + let extension = "error"; - // Validations + // File Validations try { - if (helper.isUndefined(plant_id)) { - errorMsgs.push('plant_id missing'); - } else if (!helper.isNumeric(plant_id)) { - errorMsgs.push('plant_id has to be a number'); - } else if (plant_id <= 0) { - errorMsgs.push('plant_id has to be a bigger number than 0'); - } else if(!plantDaoInstance.exists(plant_id)) { - errorMsgs.push('no plant with this plant_id'); + if (!req.files || !req.files.picture) { + console.warn('Service Upload: No file was uploaded by the client'); + return resp.status(400).json({ errors: [{msg: 'No picture was uploaded'}] }); + } + picture = req.files.picture; + if(!isValidImageMimeType(picture.mimetype)) + { + console.warn('Service Upload: Client uploaded file with MimeType "' + picture.mimetype + '" but was supposed to upload image'); + return resp.status(400).json({ errors: [{msg: 'The uploaded file had the wrong mimetype'}] }); } - if (!request.files) { - errorMsgs.push('no files provided'); + extension = picture.name.split(".").pop(); + if(!isValidImageExtension(extension)) + { + console.warn('Service Upload: Client uploaded file with extension "' + extension + '" but was supposed to upload image'); + return resp.status(400).json({ errors: [{msg: 'The uploaded file had the wrong extension'}] }); } } catch(ex) { - response.status(400).json({'fehler': true, 'nachricht': 'Error in Validation: ' + ex}); + console.error('Service Upload: Exception during file validation: ' + ex.message); + return resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } - // Send back result of validation - if (errorMsgs.length > 0) { - console.log('Service Upload: Upload not possible, data missing or invalid'); - response.status(400).json({ 'fehler': true, 'nachricht': 'Function not possible. Missing or invalid Data: ' + helper.concatArray(errorMsgs) }); - return; - } //Get image, save it and update plant try { - // get handle on file info, in this case 'picture' is the HTML Field Name - var picture = request.files.picture; - - // validate mimetype type - // Based on tests iI saw: 'image/jpeg','application/pdf' and 'application/x-msdownload' - let type = picture.mimetype.split("/")[0]; - if(type != "image") - { - console.log('Service Upload: Client uploaded file of type "' + type + '" but was supposed to upload image'); - response.status(400).json({'fehler': true, 'nachricht': 'the file sent was not of type image but "' + type + '"'}); - return; - } - // save file on server - // if target directory is not existent, it is created automatically - // exsisting files will be overwritten! + // if target directory does not exist, it will be created automatically + // existing files will be overwritten! console.log('Service Upload: saving file to target directory on server'); - let extension = picture.name.split(".").pop(); let filename = plant_id + "." + extension; picture.mv('./public/images/plants/' + filename); // about Path Traversal attacks on the .mv: - // - plant_id is validated as number and should thus be safe - // - extension can not contain . because of the split and pop + // - plant_id is validated and sanitized by the express validator and should thus be safe + // - extension is checked against a whitelist and should thus be safe // Update plant to use the uploaded picture let plant = plantDaoInstance.loadById(plant_id); @@ -95,13 +103,13 @@ serviceRouter.put('/upload/image', (request, response) => { // - as long as the filename in the DB is clean this is not an issue } - response.status(200).json({'fehler': false}); + resp.status(200).json({'success': true, 'filename': filename, 'plant_id': plant_id}); } catch(ex) { - response.status(400).json({'fehler': true, 'nachricht': 'Error in Service: ' + ex}); + console.error('Service Upload: Exception while saving file: ' + ex.message); + return resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); } - }); module.exports = serviceRouter; \ No newline at end of file diff --git a/backend/services/validationHelper.js b/backend/services/validationHelper.js new file mode 100644 index 0000000..ecdc7e5 --- /dev/null +++ b/backend/services/validationHelper.js @@ -0,0 +1,34 @@ +const plantsDao = require('../dao/plantsDao.js'); +const activitiesDao = require('../dao/activitiesDao.js'); + +// plant exists validation function +module.exports.validatePlantIDExists = (value,{req}) => { + const plantsDaoInstance = new plantsDao(req.app.locals.dbConnection); + console.log('Validating plant_id existence: ' + value); + if (!plantsDaoInstance.exists(value)) { + throw new Error('Plant with the given ID does not exist'); + } + return true; +}; + +// activity exists validation function +module.exports.validateActivityIDExists = (value,{req}) => { + const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); + console.log('Validating activity_id existence: ' + value); + if (!activitiesDaoInstance.exists(value)) { + throw new Error('Activity with the given ID does not exist'); + } + return true; +} + +/** + * Converts an exception to a JSON object. + * @param {Error} ex The exception to convert + * @returns {Object} JSON object representing the exception + */ +module.exports.exceptionToJson = (ex) => { + return { + 'msg': "An error occurred", + 'errorMsg': ex.message + }; +} \ No newline at end of file diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 0031040..06a1456 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -14,6 +14,9 @@ services: image: plantmanager-backend:test ports: - 8001:8001 + environment: + - LOG_SQLITE_QUERIES=true + - LOG_HTTP_QUERIES=true volumes: - db_vol:/home/node/app/db - images_vol:/home/node/app/public/images/plants diff --git a/docs/API.md b/docs/API.md index 792abc5..89f75da 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,7 +2,7 @@ - Die jeweiligen Services werden mit einem HTTP Request aufgerufen. Dieser ist wie folgt aufgebaut - `http://[serveradresse]:[port]/[backendname]/[servicename]/[servicemethode]/[opt.Werte]` - Wenn Sie also z.B. den Service „Plants“ aufrufen wollen um Objekte aller Pflanzen zu erhalten verwenden Sie - - `http://localhost:8000/api/plants/all` + - `http://localhost:8001/api/plants/all` # Übersicht über die einzelnen Serviceklassen Folgende Serviceklassen sind implementiert und werden als sogenannte Endpoints eingebunden: @@ -23,24 +23,24 @@ Bestehenden Eintrag in Datenbank löschen Alle HTTP Aufrufe sind englisch und kleingeschrieben! ### Daten abrufen vom Backend (GET) - Objekt vom Typ Plant mit der ID 3 holen -- http://localhost:8000/api/plants/get/3 +- http://localhost:8001/api/plants/get/3 ### Alle Plant – Objekte holen -- http://localhost:8000/api/plants/all +- http://localhost:8001/api/plants/all ### Prüfen ob ein Eintrag mit der ID 7 existiert -- http://localhost:8000/api/plants/exsists/7 +- http://localhost:8001/api/plants/exsists/7 ### Neuen Eintrag erstellen (POST) -- http://localhost:8000/api/plants +- http://localhost:8001/api/plants - Wobei die Daten hier als JSON Objekt vom jeweiligen Typ geliefert werden müssen, ohne ID ### Bestehenden Eintrag ändern (PUT) -- http://localhost:8000/api/plants +- http://localhost:8001/api/plants - Wobei die Daten hier als JSON Objekt vom jeweiligen Typ geliefert werden müssen, mit ID ### Bestehenden Eintrag löschen (DELETE) -- http://localhost:8000/api/plants/4 +- http://localhost:8001/api/plants/4 # Daten an Servicemethoden senden - Entweder als Teil des RESTFul Aufrufs (z.b. in Form einer ID) - - http://localhost:8000/api/plants/get/2 + - http://localhost:8001/api/plants/get/2 - oder als - JSON Objekt,welches im HTTP Body übertragen wird - Bei den JSON Objekten sind alle Eigentschaftsnamen ebenfalls kleingeschrieben. @@ -55,19 +55,66 @@ Alle HTTP Aufrufe sind englisch und kleingeschrieben! - Im Erfolgsfall, wenn die Verarebeitung geklappt hat, ein Objekt mit den angeforderten Daten. - Oder im Fehlerfall ein Objekt mit einer Fehlermeldung -## Beispiel im Erfolgsfall +## Beispiele im Erfolgsfall +### Put oder Get einzelner Daten ```JSON { } ``` - -## Beispiel im Fehlerfall +### Get aller Daten +```JSON +[ + { }, + { }, + { } +] +``` +### Existenzprüfung +```JSON +{ + "plant_id": 1, + "exists": false +} +``` +### Löschen einzelnen Daten +```JSON +{ + "plant_id": 2, + "deleted": true +} +``` +## Beispiele im Fehlerfall +### Validierungsfehler (Error 400) +```JSON +{ + "errors": [ + { + "type": "field", + "value": 0, + "msg": "Plant with the given ID does not exist", + "path": "plant_id", + "location": "body" + }, + { + "type": "field", + "value": "2025-13-32", + "msg": "Invalid value", + "path": "composted", + "location": "body" + } + ] +} +``` +### Server Fehler (Error 500) ```JSON { - "nachricht": "Fehler: name fehlt", - "fehler": true, - "daten": null + "errors": [ + { + "msg": "An error occurred", + "errorMsg": "Test error" + } + ] } ``` diff --git a/docs/docker-compose-example.yaml b/docs/docker-compose-example.yaml index a505269..1f6a9f8 100644 --- a/docs/docker-compose-example.yaml +++ b/docs/docker-compose-example.yaml @@ -11,6 +11,9 @@ services: image: ghcr.io/quadcoredevelopment/plantmanager_backend:latest ports: - 8001:8001 + environment: + - LOG_SQLITE_QUERIES=false # set to "true" (lowercase) to enable query logging + - LOG_HTTP_QUERIES=false # set to "true" (lowercase) to enable HTTP request logging volumes: - db_vol:/home/node/app/db - images_vol:/home/node/app/public/images/plants diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4ae852..6c15b58 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "plant_manager_frontend", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plant_manager_frontend", - "version": "1.0.0", + "version": "1.2.2", "license": "ISC", "dependencies": { "express": "^5.1.0" @@ -1164,9 +1164,9 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index a1f5eba..8df3d42 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "plant_manager_frontend", - "version": "1.2.1", + "version": "1.2.2", "description": "Frontend for PlantManager", "main": "client.js", "scripts": { diff --git a/frontend/public/dev/index.mjs b/frontend/public/dev/index.mjs index 9c93741..a232680 100644 --- a/frontend/public/dev/index.mjs +++ b/frontend/public/dev/index.mjs @@ -13,6 +13,7 @@ async function onButtonAlertClick() { testAlert2(); testAlert3(); testAlert4(); + testAlert5(); } @@ -45,6 +46,12 @@ function testAlert4(){ alerts.displayAlert(mainText, "warning", secondaryText, "bi-exclamation-triangle-fill"); } +function testAlert5(){ + const mainText = "Multiline Error Alert"; + const secondaryText = "Dies ist eine mehrzeilige Fehlermeldung.\nBitte überprüfen Sie die Eingaben und versuchen Sie es erneut.\nFalls das Problem weiterhin besteht, wenden Sie sich an den Support."; + alerts.displayError(mainText, secondaryText); +} + async function init() { alerts.initializeAlertDisplay(); registerEventHandlers(); diff --git a/frontend/public/mjs/alerts.mjs b/frontend/public/mjs/alerts.mjs index 76f2cfa..410b6dd 100644 --- a/frontend/public/mjs/alerts.mjs +++ b/frontend/public/mjs/alerts.mjs @@ -46,9 +46,14 @@ export function displayAlert(mainMessage, type, secondaryMessage, icon=null) { div.append(strong); if(secondaryMessage != null) { + let parts = secondaryMessage.split('\n'); let secondDiv = $('
'); div.append(secondDiv); - secondDiv.text(secondaryMessage); + for (let i = 0; i < parts.length; i++) { + let p = $('

'); + p.text(parts[i]); + secondDiv.append(p); + } } alert.append($('')); @@ -64,5 +69,5 @@ export function displayAlert(mainMessage, type, secondaryMessage, icon=null) { */ export function displayError(mainMessage, secondaryMessage) { - displayAlert(mainMessage, "danger", secondaryMessage, "bi-x-circle"); + displayAlert(mainMessage, "danger", secondaryMessage, "bi-x-circle-fill"); } \ No newline at end of file diff --git a/frontend/public/mjs/backend_api.mjs b/frontend/public/mjs/backend_api.mjs index 183d7ff..a60d7ca 100644 --- a/frontend/public/mjs/backend_api.mjs +++ b/frontend/public/mjs/backend_api.mjs @@ -1,5 +1,6 @@ import { backendUrl_api } from "./config.mjs"; import * as utils from "./utils.mjs"; +import { BackendError } from "./customErrors.mjs"; /** * async function to create a new plant on the backend. @@ -27,8 +28,8 @@ export async function createPlant(){ // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to create plant - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to create plant`,res.status, errorResponse.errors); } console.log("created new plant"); let createdId = JSON.parse(JSON.stringify(await res.json())).plant_id; @@ -68,8 +69,8 @@ export async function createActivity(plant_id, type) // check if it was successful if (res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to create activity with type ${type} - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to create activity with type ${type}`,res.status, errorResponse.errors); } else { @@ -119,8 +120,8 @@ export async function deleteActivity(id) { // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to delete activity - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to delete activity`,res.status, errorResponse.errors); } else console.log("Activity deleted successfully"); @@ -148,8 +149,8 @@ export async function fetchPlants(onlyCompsted=false) { const res = await fetch(backendUrl_api + endpoint); // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to fetch plants - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to fetch plants`,res.status, errorResponse.errors); } else { @@ -176,13 +177,8 @@ export async function fetchPlant(plant_id) { const res = await fetch(backendUrl_api + '/plants/get/' + plant_id); // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - const prefix = "Failed to fetch plant"; - if(errorResponse.includes("No record found")) - { - throw new Error(prefix + ` - plant does not exist`); - } - throw new Error(prefix + ` - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to fetch plant`,res.status, errorResponse.errors); } else { @@ -210,8 +206,8 @@ export async function fetchActivities(plant_id) { const res = await fetch(backendUrl_api + '/activities/all/' + plant_id); // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to fetch activities - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to fetch activities`,res.status, errorResponse.errors); } else { @@ -242,8 +238,8 @@ export async function uploadImageForPlant(formData) { // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to upload image - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to upload image`,res.status, errorResponse.errors); } else { @@ -272,8 +268,8 @@ export async function deletePlant(plant_id) { // check if it was successful if(res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to delete plant - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to delete plant`,res.status, errorResponse.errors); } else { @@ -345,8 +341,8 @@ export async function updatePlant(plant) { // check if it was successful if (res.status !== 200) { - const errorResponse = await res.text(); - throw new Error(`Failed to update plant - Error ${res.status}: ${errorResponse}`); + const errorResponse = await res.json(); + throw new BackendError(`Failed to update plant`,res.status, errorResponse.errors); } else { diff --git a/frontend/public/mjs/customErrors.mjs b/frontend/public/mjs/customErrors.mjs new file mode 100644 index 0000000..8c2ad13 --- /dev/null +++ b/frontend/public/mjs/customErrors.mjs @@ -0,0 +1,24 @@ +/** + * Custom error class to represent backend-related errors. + */ +export class BackendError extends Error { + + /** + * Creates an instance of BackendError. + * @param {string} message A descriptive error message. + * @param {number} httpStatusCode The HTTP status code associated with the error. + * @param {Array} errorArray An array of error details from the backend. + */ + constructor(message, httpStatusCode, errorArray) { + super(message); + + // Maintains proper stack trace for where our error was thrown (non-standard) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BackendError); + } + + this.name = "BackendError"; + this.httpStatusCode = httpStatusCode; + this.errorArray = errorArray; + } +} \ No newline at end of file diff --git a/frontend/public/mjs/error_handler.mjs b/frontend/public/mjs/error_handler.mjs index 8a8aa56..d65d3cb 100644 --- a/frontend/public/mjs/error_handler.mjs +++ b/frontend/public/mjs/error_handler.mjs @@ -2,72 +2,128 @@ import * as alerts from "./alerts.mjs"; /** * Takes an error aka. exception, generates a user-friendly message for it and displays it. - * Requires a initialized alerts display. + * Requires an initialized alerts display. * @param {Error} error The Error for which a message should be displayed. */ export function handleError(error) { console.error("Handling error:", error); // generate main message - let mainMessage = "Ein unbekannter Fehler ist aufgetreten"; + let mainMessage = generatePrimaryErrorMessage(error); + + // generate secondary message + let secondaryMessage = generateSecondaryErrorMessage(error); + + // Display the error message + alerts.displayError(mainMessage, secondaryMessage); +} + +function generatePrimaryErrorMessage(error) { switch (error.name) { case "TypeError": if (error.message.includes("NetworkError")) { - mainMessage = "Fehler bei der Kommunikation mit dem Backend"; + return "Fehler bei der Kommunikation mit dem Backend"; } break; - default: + case "BackendError": if (error.message.includes("Failed to fetch plants")) { - mainMessage = "Fehler beim Abrufen der Pflanzen"; + return "Fehler beim Abrufen der Pflanzen"; } else if (error.message.includes("Failed to fetch plant")) { - mainMessage = "Fehler beim Abrufen der Pflanze"; + return "Fehler beim Abrufen der Pflanze"; } else if (error.message.includes("Failed to create plant")) { - mainMessage = "Konnte keine neue Pflanze hinzufügen"; + return "Konnte keine neue Pflanze hinzufügen"; } else if (error.message.includes("Failed to fetch activities")) { - mainMessage = "Konnte keine Aktivitäten zu dieser Pflanze abrufen"; + return "Konnte keine Aktivitäten zu dieser Pflanze abrufen"; } else if (error.message.includes("Failed to upload image")) { - mainMessage = "Fehler beim Hochladen des Bildes"; + return "Fehler beim Hochladen des Bildes"; } else if (error.message.includes("Failed to delete plant")) { - mainMessage = "Fehler beim Löschen der Pflanze"; + return "Fehler beim Löschen der Pflanze"; } else if (error.message.includes("Failed to delete activity")) { - mainMessage = "Fehler beim Löschen der Aktivität"; + return "Fehler beim Löschen der Aktivität"; } else if (error.message.includes("Failed to update plant")) { - mainMessage = "Fehler beim aktualisieren der Pflanze"; + return "Fehler beim aktualisieren der Pflanze"; } else if (error.message.includes("Failed to create activity")) { if(error.message.includes("type 0")) { - mainMessage = "Konnte die Pflanze nicht bewässern"; + return "Konnte die Pflanze nicht bewässern"; } else if(error.message.includes("type 1")) { - mainMessage = "Konnte die Pflanze nicht umtopfen"; + return "Konnte die Pflanze nicht umtopfen"; } else{ - mainMessage = "Konnte keine neue Aktivität anlegen"; + return "Konnte keine neue Aktivität anlegen"; } } break; } - // generate secondary message - let secondaryMessage = null; - - if (error.message.includes("Error 404")) { - secondaryMessage = "Das Backend unterstützt die benötigte API nicht. Möglicherweise wird ein Update benötigt."; - } else if (error.message.includes("Error 400")) { - if (error.message.includes("was not of type image")) { - secondaryMessage = "Es können nur Bild Dateien hochgeladen werden. Bitte wählen Sie eine andere Datei."; - } else if (error.message.includes("Missing or invalid data")) { - secondaryMessage = "Die eingegebenen Daten sind ungültig oder unvollständig"; - } else { - secondaryMessage = "Das Backend konnte die Anfrage nicht verarbeiten. Möglicherweise benötigt das Backend weitere Informationen oder das Frontend ist nicht kompatibel."; - } - } else if (error.message.includes("plant does not exist")) { - secondaryMessage = "Die gewünschte Pflanze existiert nicht. Möglicherweise wurde sie gelöscht."; + return "Ein unbekannter Fehler ist aufgetreten"; +} + +function generateSecondaryErrorMessage(error) { + + switch (error.name) { + case "TypeError": + if (error.message.includes("NetworkError")) { + return "Es konnte keine Verbindung zum Backend hergestellt werden. Bitte überprüfen Sie die Netzwerkkonfiguration und ob das Backend erreichbar ist."; + } + break; + + case "BackendError": + return generateSecondaryErrorMessage_BackendError(error); } - // Display the error message - alerts.displayError(mainMessage, secondaryMessage); + return "Es ist ein unbekannter Fehler aufgetreten. \nFehlertyp: " + error.name + " \nFehlerdetails: " + error.message; +} + +/** + * Generates a secondary, more detailed error message for BackendErrors. + * @param {BackendError} error The BackendError for which a message should be generated. + * @returns {string} The generated secondary error message. + */ +function generateSecondaryErrorMessage_BackendError(error) { + + switch (error.httpStatusCode) { + case 404: + return "Das Backend unterstützt die benötigte API nicht. Möglicherweise wird ein Update des Frontends oder Backends benötigt." + + case 400:{ + let errorMsg = ""; + for (let i = 0; i < error.errorArray.length; i++) { + let err = error.errorArray[i]; + + switch (err.msg) { + case "Invalid value": + errorMsg += `Der Wert für das Feld "${err.path}" ist ungültig.`; + break; + case "Plant with the given ID does not exist": + errorMsg += `Die Pflanze mit der ID "${err.value}" existiert nicht. Möglicherweise wurde sie gelöscht.`; + break; + case "No picture was uploaded": + errorMsg += `Es wurde kein Bild zum Hochladen ausgewählt. Bitte wählen Sie eine Bilddatei aus.`; + break; + case "The uploaded file had the wrong mimetype": + errorMsg += `Die hochgeladene Datei hat einen ungültigen Dateityp. Bitte laden Sie eine Bilddatei hoch.`; + break; + case "The uploaded file had the wrong extension": + errorMsg += `Die hochgeladene Datei hat eine ungültige Dateiendung. Bitte laden Sie eine Bilddatei hoch.`; + break; + default: + errorMsg += `Es ist ein unbekannter Fehler aufgetreten: ${err.msg}`; + } + + if( i < error.errorArray.length - 1 ){ + errorMsg += "\n"; + } + } + return errorMsg } + + case 500: + return `Im Backend ist ein interner Fehler aufgetreten. Fehlerdetails: ${error.errorArray[0].errorMsg}`; + } + + return `Es ist ein unbekannter Fehler im Backend aufgetreten. HTTP-Statuscode: ${error.httpStatusCode}`; } \ No newline at end of file diff --git a/frontend/templates/commonHead.html b/frontend/templates/commonHead.html index 0171232..38839f1 100644 --- a/frontend/templates/commonHead.html +++ b/frontend/templates/commonHead.html @@ -10,4 +10,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/templates/nav.html b/frontend/templates/nav.html index a5efbae..c87975e 100644 --- a/frontend/templates/nav.html +++ b/frontend/templates/nav.html @@ -2,7 +2,7 @@

- Logo + Logo