From e83a51a051368b835e2fed078ac0417d0cdb0996 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Wed, 8 Apr 2026 10:19:12 -0400 Subject: [PATCH] admin --- backend/src/routes/admin.js | 11 +++- backend/src/routes/auth.js | 29 +-------- backend/src/routes/nozzles.js | 17 +++-- backend/src/routes/properties.js | 38 +++++++---- backend/src/routes/weather.js | 5 +- backend/src/services/adminSettingsService.js | 68 ++++++++++++++++++++ backend/src/utils/validation.js | 29 ++++++++- 7 files changed, 148 insertions(+), 49 deletions(-) create mode 100644 backend/src/services/adminSettingsService.js diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7b81f660..ef2fa30f 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,7 +1,12 @@ const express = require('express'); const pool = require('../config/database'); -const { validateRequest, validateParams } = require('../utils/validation'); -const { productSchema, idParamSchema } = require('../utils/validation'); +const { + validateRequest, + validateParams, + productSchema, + idParamSchema, + propertySectionParamSchema +} = require('../utils/validation'); const { requireAdmin } = require('../middleware/auth'); const { AppError } = require('../middleware/errorHandler'); @@ -1172,7 +1177,7 @@ router.get('/properties/:id', validateParams(idParamSchema), async (req, res, ne }); // Update a lawn section (admin override) -router.put('/properties/:propertyId/sections/:sectionId', validateParams(idParamSchema), async (req, res, next) => { +router.put('/properties/:propertyId/sections/:sectionId', validateParams(propertySectionParamSchema), async (req, res, next) => { try { const { propertyId, sectionId } = req.params; const { name, area, polygonData, grassType, grassTypes, captureMethod, captureMeta } = req.body; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d49c4d0c..fb469e91 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -8,6 +8,7 @@ const { validateRequest } = require('../utils/validation'); const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); const { authenticateToken } = require('../middleware/auth'); +const { getRegistrationEnabled } = require('../services/adminSettingsService'); const router = express.Router(); @@ -338,31 +339,7 @@ router.get('/me', authenticateToken, async (req, res, next) => { // @access Public router.get('/registration-status', async (req, res, next) => { try { - // Create admin_settings table if it doesn't exist - await pool.query(` - CREATE TABLE IF NOT EXISTS admin_settings ( - id SERIAL PRIMARY KEY, - setting_key VARCHAR(255) UNIQUE NOT NULL, - setting_value TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Insert default registration setting if it doesn't exist - await pool.query(` - INSERT INTO admin_settings (setting_key, setting_value) - VALUES ('registrationEnabled', 'true') - ON CONFLICT (setting_key) DO NOTHING - `); - - // Get registration setting - const settingResult = await pool.query( - 'SELECT setting_value FROM admin_settings WHERE setting_key = $1', - ['registrationEnabled'] - ); - - const enabled = settingResult.rows.length > 0 ? settingResult.rows[0].setting_value === 'true' : true; + const enabled = await getRegistrationEnabled(); res.json({ success: true, @@ -375,4 +352,4 @@ router.get('/registration-status', async (req, res, next) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/nozzles.js b/backend/src/routes/nozzles.js index 49404448..d33e2c98 100644 --- a/backend/src/routes/nozzles.js +++ b/backend/src/routes/nozzles.js @@ -1,9 +1,18 @@ const express = require('express'); +const Joi = require('joi'); const pool = require('../config/database'); -const { validateRequest, validateParams } = require('../utils/validation'); -const { idParamSchema } = require('../utils/validation'); +const { + validateRequest, + validateParams, + idParamSchema +} = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); +const flowRateParamSchema = Joi.object({ + nozzleTypeId: Joi.number().integer().positive().required(), + pressurePsi: Joi.number().integer().positive().required() +}); + const router = express.Router(); // @route GET /api/nozzles/types @@ -107,7 +116,7 @@ router.get('/types', async (req, res, next) => { // @route GET /api/nozzles/flow-rate/:nozzleTypeId/:pressurePsi // @desc Get flow rate for specific nozzle at specific pressure // @access Private -router.get('/flow-rate/:nozzleTypeId/:pressurePsi', validateParams(idParamSchema), async (req, res, next) => { +router.get('/flow-rate/:nozzleTypeId/:pressurePsi', validateParams(flowRateParamSchema), async (req, res, next) => { try { const { nozzleTypeId, pressurePsi } = req.params; @@ -855,4 +864,4 @@ router.get('/configurations/:configId/details', validateParams(idParamSchema), a } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js index ee2188a4..ccd3c17c 100644 --- a/backend/src/routes/properties.js +++ b/backend/src/routes/properties.js @@ -1,19 +1,31 @@ const express = require('express'); const pool = require('../config/database'); -const { validateRequest, validateParams } = require('../utils/validation'); -const { propertySchema, lawnSectionSchema, idParamSchema } = require('../utils/validation'); +const { + validateRequest, + validateParams, + propertySchema, + lawnSectionSchema, + idParamSchema, + propertySectionParamSchema +} = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); // Geometry helpers for sanitization and simplification are defined below const router = express.Router(); +const shouldLogAreaDebug = process.env.PROPERTY_AREA_DEBUG === 'true'; +const areaDebug = (...args) => { + if (shouldLogAreaDebug) { + console.log(...args); + } +}; // Helper function to calculate polygon area (in square feet) const calculatePolygonArea = (coordinates) => { if (!coordinates || coordinates.length < 3) { - console.log('Invalid coordinates for area calculation:', coordinates); + areaDebug('Invalid coordinates for area calculation:', coordinates); return 0; } - console.log('Calculating area for coordinates:', coordinates); + areaDebug('Calculating area for coordinates:', coordinates); let area = 0; const n = coordinates.length; for (let i = 0; i < n; i++) { @@ -22,15 +34,15 @@ const calculatePolygonArea = (coordinates) => { area -= coordinates[j][1] * coordinates[i][0]; } area = Math.abs(area) / 2; - console.log('Raw shoelace area (in deg²):', area); + areaDebug('Raw shoelace area (in deg²):', area); const avgLat = coordinates.reduce((sum, coord) => sum + coord[0], 0) / n; - console.log('Average latitude:', avgLat); + areaDebug('Average latitude:', avgLat); const metersPerDegreeLat = 111320; const metersPerDegreeLng = 111320 * Math.cos(avgLat * Math.PI / 180); const areaInSquareMeters = area * metersPerDegreeLat * metersPerDegreeLng; const areaInSquareFeet = areaInSquareMeters * 10.7639; - console.log('Area in square meters:', areaInSquareMeters); - console.log('Area in square feet:', areaInSquareFeet); + areaDebug('Area in square meters:', areaInSquareMeters); + areaDebug('Area in square feet:', areaInSquareFeet); return areaInSquareFeet; }; @@ -345,10 +357,10 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn // Calculate area from polygon if provided let calculatedArea = area; if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) { - console.log('Original polygon data:', JSON.stringify(polygonData, null, 2)); - console.log('Coordinates for calculation:', polygonData.coordinates[0]); + areaDebug('Original polygon data:', JSON.stringify(polygonData, null, 2)); + areaDebug('Coordinates for calculation:', polygonData.coordinates[0]); calculatedArea = calculatePolygonArea(polygonData.coordinates[0]); - console.log('Calculated area:', calculatedArea); + areaDebug('Calculated area:', calculatedArea); } // Sanitize polygon before storing @@ -409,7 +421,7 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn // @route PUT /api/properties/:propertyId/sections/:sectionId // @desc Update lawn section // @access Private -router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { +router.put('/:propertyId/sections/:sectionId', validateParams(propertySectionParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => { try { const { propertyId, sectionId } = req.params; const { name, area, polygonData, grassType, grassTypes, soilType, captureMethod, captureMeta } = req.body; @@ -490,7 +502,7 @@ router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { // @route DELETE /api/properties/:propertyId/sections/:sectionId // @desc Delete lawn section // @access Private -router.delete('/:propertyId/sections/:sectionId', async (req, res, next) => { +router.delete('/:propertyId/sections/:sectionId', validateParams(propertySectionParamSchema), async (req, res, next) => { try { const { propertyId, sectionId } = req.params; diff --git a/backend/src/routes/weather.js b/backend/src/routes/weather.js index c7dd03e7..2eb9eaf8 100644 --- a/backend/src/routes/weather.js +++ b/backend/src/routes/weather.js @@ -2,13 +2,14 @@ const express = require('express'); const axios = require('axios'); const pool = require('../config/database'); const { AppError } = require('../middleware/errorHandler'); +const { validateParams, propertyIdParamSchema } = require('../utils/validation'); const router = express.Router(); // @route GET /api/weather/:propertyId // @desc Get current weather for property location // @access Private -router.get('/:propertyId', async (req, res, next) => { +router.get('/:propertyId', validateParams(propertyIdParamSchema), async (req, res, next) => { try { const propertyId = req.params.propertyId; @@ -165,7 +166,7 @@ router.get('/:propertyId', async (req, res, next) => { // @route GET /api/weather/:propertyId/forecast // @desc Get 5-day weather forecast for property // @access Private -router.get('/:propertyId/forecast', async (req, res, next) => { +router.get('/:propertyId/forecast', validateParams(propertyIdParamSchema), async (req, res, next) => { try { const propertyId = req.params.propertyId; diff --git a/backend/src/services/adminSettingsService.js b/backend/src/services/adminSettingsService.js new file mode 100644 index 00000000..0eb8f21f --- /dev/null +++ b/backend/src/services/adminSettingsService.js @@ -0,0 +1,68 @@ +const pool = require('../config/database'); + +const REGISTRATION_KEY = 'registrationEnabled'; +const CACHE_TTL_MS = parseInt(process.env.ADMIN_SETTINGS_CACHE_TTL_MS, 10) || 60000; + +let cache = { value: null, expiresAt: 0 }; +let initPromise; + +const ensureInitialized = async () => { + if (!initPromise) { + initPromise = (async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS admin_settings ( + id SERIAL PRIMARY KEY, + setting_key VARCHAR(255) UNIQUE NOT NULL, + setting_value TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + await pool.query(` + INSERT INTO admin_settings (setting_key, setting_value) + VALUES ($1, $2) + ON CONFLICT (setting_key) DO NOTHING + `, [REGISTRATION_KEY, 'true']); + })().catch((err) => { + initPromise = null; + throw err; + }); + } + + return initPromise; +}; + +const readSetting = async (key, fallback = 'true') => { + await ensureInitialized(); + const result = await pool.query( + 'SELECT setting_value FROM admin_settings WHERE setting_key = $1', + [key] + ); + if (result.rows.length === 0) return fallback; + return result.rows[0].setting_value; +}; + +const getRegistrationEnabled = async () => { + const now = Date.now(); + if (cache.expiresAt > now && cache.value !== null) { + return cache.value; + } + + const value = await readSetting(REGISTRATION_KEY); + const enabled = value === 'true'; + cache = { + value: enabled, + expiresAt: now + CACHE_TTL_MS + }; + return enabled; +}; + +const clearAdminSettingsCache = () => { + cache = { value: null, expiresAt: 0 }; +}; + +module.exports = { + getRegistrationEnabled, + clearAdminSettingsCache +}; diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index 45476299..63da8b6a 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -228,7 +228,23 @@ const validateRequest = (schema) => { const validateParams = (schema) => { return (req, res, next) => { - const { error } = schema.validate(req.params, { abortEarly: false }); + let paramsToValidate = req.params; + try { + const schemaDescription = schema.describe(); + const expectedKeys = schemaDescription?.keys ? Object.keys(schemaDescription.keys) : []; + if ( + expectedKeys.length === 1 && + !Object.prototype.hasOwnProperty.call(req.params, expectedKeys[0]) && + Object.keys(req.params).length === 1 + ) { + const [actualKey] = Object.keys(req.params); + paramsToValidate = { [expectedKeys[0]]: req.params[actualKey] }; + } + } catch (_) { + // Ignore schema introspection issues and fall back to default validation. + } + + const { error } = schema.validate(paramsToValidate, { abortEarly: false }); if (error) { return next(error); } @@ -240,6 +256,15 @@ const idParamSchema = Joi.object({ id: Joi.number().integer().positive().required() }); +const propertyIdParamSchema = Joi.object({ + propertyId: Joi.number().integer().positive().required() +}); + +const propertySectionParamSchema = Joi.object({ + propertyId: Joi.number().integer().positive().required(), + sectionId: Joi.number().integer().positive().required() +}); + module.exports = { // Schemas registerSchema, @@ -257,6 +282,8 @@ module.exports = { mowingSessionSchema, mowingPlanSchema, idParamSchema, + propertyIdParamSchema, + propertySectionParamSchema, // Middleware validateRequest,