const express = require('express'); const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); const { productSchema, idParamSchema } = require('../utils/validation'); const { requireAdmin } = require('../middleware/auth'); const { AppError } = require('../middleware/errorHandler'); const router = express.Router(); // Apply admin middleware to all routes router.use(requireAdmin); // @route GET /api/admin/dashboard // @desc Get admin dashboard statistics // @access Private (Admin) router.get('/dashboard', async (req, res, next) => { try { const statsQuery = ` SELECT (SELECT COUNT(*) FROM users) as total_users, (SELECT COUNT(*) FROM users WHERE role = 'admin') as admin_users, (SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days') as new_users_30d, (SELECT COUNT(*) FROM properties) as total_properties, (SELECT COUNT(*) FROM user_equipment) as total_equipment, (SELECT COUNT(*) FROM products) as total_products, (SELECT COUNT(*) FROM user_products) as custom_products, (SELECT COUNT(*) FROM application_plans) as total_plans, (SELECT COUNT(*) FROM application_logs) as total_applications, (SELECT COUNT(*) FROM application_logs WHERE application_date >= CURRENT_DATE - INTERVAL '7 days') as recent_applications `; const statsResult = await pool.query(statsQuery); const stats = statsResult.rows[0]; // Get user activity (users with recent activity) const userActivityQuery = ` SELECT DATE_TRUNC('day', created_at) as date, COUNT(*) as new_registrations FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days' GROUP BY DATE_TRUNC('day', created_at) ORDER BY date DESC `; const activityResult = await pool.query(userActivityQuery); // Get application activity const applicationActivityQuery = ` SELECT DATE_TRUNC('day', application_date) as date, COUNT(*) as applications, COUNT(DISTINCT user_id) as active_users FROM application_logs WHERE application_date >= CURRENT_DATE - INTERVAL '30 days' GROUP BY DATE_TRUNC('day', application_date) ORDER BY date DESC `; const appActivityResult = await pool.query(applicationActivityQuery); res.json({ success: true, data: { stats: { totalUsers: parseInt(stats.total_users), adminUsers: parseInt(stats.admin_users), newUsers30d: parseInt(stats.new_users_30d), totalProperties: parseInt(stats.total_properties), totalEquipment: parseInt(stats.total_equipment), totalProducts: parseInt(stats.total_products), customProducts: parseInt(stats.custom_products), totalPlans: parseInt(stats.total_plans), totalApplications: parseInt(stats.total_applications), recentApplications: parseInt(stats.recent_applications) }, userActivity: activityResult.rows.map(row => ({ date: row.date, newRegistrations: parseInt(row.new_registrations) })), applicationActivity: appActivityResult.rows.map(row => ({ date: row.date, applications: parseInt(row.applications), activeUsers: parseInt(row.active_users) })) } }); } catch (error) { next(error); } }); // @route GET /api/admin/users // @desc Get all users with pagination // @access Private (Admin) router.get('/users', async (req, res, next) => { try { const { page = 1, limit = 20, search, role } = req.query; const offset = (page - 1) * limit; let whereConditions = []; let queryParams = []; let paramCount = 0; if (search) { paramCount++; whereConditions.push(`(first_name ILIKE $${paramCount} OR last_name ILIKE $${paramCount} OR email ILIKE $${paramCount})`); queryParams.push(`%${search}%`); } if (role) { paramCount++; whereConditions.push(`role = $${paramCount}`); queryParams.push(role); } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; // Get total count const countQuery = `SELECT COUNT(*) as total FROM users ${whereClause}`; const countResult = await pool.query(countQuery, queryParams); const totalUsers = parseInt(countResult.rows[0].total); // Get users with stats paramCount++; queryParams.push(limit); paramCount++; queryParams.push(offset); const usersQuery = ` SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.oauth_provider, u.created_at, u.updated_at, COUNT(DISTINCT p.id) as property_count, COUNT(DISTINCT ap.id) as application_count, MAX(al.application_date) as last_application FROM users u LEFT JOIN properties p ON u.id = p.user_id LEFT JOIN application_plans ap ON u.id = ap.user_id LEFT JOIN application_logs al ON u.id = al.user_id ${whereClause} GROUP BY u.id ORDER BY u.created_at DESC LIMIT $${paramCount - 1} OFFSET $${paramCount} `; const usersResult = await pool.query(usersQuery, queryParams); res.json({ success: true, data: { users: usersResult.rows.map(user => ({ id: user.id, email: user.email, firstName: user.first_name, lastName: user.last_name, role: user.role, oauthProvider: user.oauth_provider, propertyCount: parseInt(user.property_count), applicationCount: parseInt(user.application_count), lastApplication: user.last_application, createdAt: user.created_at, updatedAt: user.updated_at })), pagination: { currentPage: parseInt(page), totalPages: Math.ceil(totalUsers / limit), totalUsers, hasNext: (page * limit) < totalUsers, hasPrev: page > 1 } } }); } catch (error) { next(error); } }); // @route PUT /api/admin/users/:id/role // @desc Update user role // @access Private (Admin) router.put('/users/:id/role', validateParams(idParamSchema), async (req, res, next) => { try { const userId = req.params.id; const { role } = req.body; if (!['admin', 'user'].includes(role)) { throw new AppError('Invalid role', 400); } // Prevent removing admin role from yourself if (parseInt(userId) === req.user.id && role !== 'admin') { throw new AppError('Cannot remove admin role from yourself', 400); } // Check if user exists const userCheck = await pool.query( 'SELECT id, role FROM users WHERE id = $1', [userId] ); if (userCheck.rows.length === 0) { throw new AppError('User not found', 404); } // Update role const result = await pool.query( 'UPDATE users SET role = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id, email, role', [role, userId] ); const user = result.rows[0]; res.json({ success: true, message: 'User role updated successfully', data: { user: { id: user.id, email: user.email, role: user.role } } }); } catch (error) { next(error); } }); // @route PUT /api/admin/users/:id // @desc Update user details // @access Private (Admin) router.put('/users/:id', validateParams(idParamSchema), async (req, res, next) => { try { const userId = req.params.id; const { firstName, lastName, email, role, password } = req.body; // Validate required fields if (!firstName || !lastName || !email || !role) { throw new AppError('First name, last name, email, and role are required', 400); } if (!['admin', 'user'].includes(role)) { throw new AppError('Invalid role', 400); } // Prevent removing admin role from yourself if (parseInt(userId) === req.user.id && role !== 'admin') { throw new AppError('Cannot remove admin role from yourself', 400); } // Check if user exists const userCheck = await pool.query( 'SELECT id, role FROM users WHERE id = $1', [userId] ); if (userCheck.rows.length === 0) { throw new AppError('User not found', 404); } // Check if email is already taken by another user const emailCheck = await pool.query( 'SELECT id FROM users WHERE email = $1 AND id != $2', [email, userId] ); if (emailCheck.rows.length > 0) { throw new AppError('Email already exists', 400); } let updateQuery = 'UPDATE users SET first_name = $1, last_name = $2, email = $3, role = $4, updated_at = CURRENT_TIMESTAMP'; let queryParams = [firstName, lastName, email, role]; // If password is provided, hash it and include in update if (password) { const bcrypt = require('bcrypt'); const saltRounds = 10; const hashedPassword = await bcrypt.hash(password, saltRounds); updateQuery += ', password_hash = $5'; queryParams.push(hashedPassword); } updateQuery += ' WHERE id = $' + (queryParams.length + 1) + ' RETURNING id, email, first_name, last_name, role'; queryParams.push(userId); const result = await pool.query(updateQuery, queryParams); const user = result.rows[0]; res.json({ success: true, message: 'User updated successfully', data: { user: { id: user.id, email: user.email, firstName: user.first_name, lastName: user.last_name, role: user.role } } }); } catch (error) { next(error); } }); // @route DELETE /api/admin/users/:id // @desc Delete user account // @access Private (Admin) router.delete('/users/:id', validateParams(idParamSchema), async (req, res, next) => { try { const userId = req.params.id; // Prevent deleting yourself if (parseInt(userId) === req.user.id) { throw new AppError('Cannot delete your own account', 400); } // Check if user exists const userCheck = await pool.query( 'SELECT id, email FROM users WHERE id = $1', [userId] ); if (userCheck.rows.length === 0) { throw new AppError('User not found', 404); } const user = userCheck.rows[0]; // Delete user (cascading will handle related records) await pool.query('DELETE FROM users WHERE id = $1', [userId]); res.json({ success: true, message: `User ${user.email} deleted successfully` }); } catch (error) { next(error); } }); // @route GET /api/admin/products // @desc Get all products for management // @access Private (Admin) router.get('/products', async (req, res, next) => { try { const { category, search } = req.query; let whereConditions = []; let queryParams = []; let paramCount = 0; if (category) { paramCount++; whereConditions.push(`p.category_id = $${paramCount}`); queryParams.push(category); } if (search) { paramCount++; whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount})`); queryParams.push(`%${search}%`); } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT p.*, pc.name as category_name, COUNT(pr.id) as rate_count, COUNT(up.id) as usage_count FROM products p JOIN product_categories pc ON p.category_id = pc.id LEFT JOIN product_rates pr ON p.id = pr.product_id LEFT JOIN user_products up ON p.id = up.product_id ${whereClause} GROUP BY p.id, pc.name ORDER BY p.name`, queryParams ); res.json({ success: true, data: { products: result.rows.map(product => ({ id: product.id, name: product.name, brand: product.brand, categoryName: product.category_name, productType: product.product_type, activeIngredients: product.active_ingredients, seedBlend: product.seed_blend, description: product.description, rateCount: parseInt(product.rate_count), usageCount: parseInt(product.usage_count), createdAt: product.created_at })) } }); } catch (error) { next(error); } }); // @route POST /api/admin/products // @desc Create new product // @access Private (Admin) router.post('/products', validateRequest(productSchema), async (req, res, next) => { try { const { name, brand, categoryId, productType, activeIngredients, description, seedBlend } = req.body; // Check if category exists const categoryCheck = await pool.query( 'SELECT id FROM product_categories WHERE id = $1', [categoryId] ); if (categoryCheck.rows.length === 0) { throw new AppError('Product category not found', 404); } const result = await pool.query( `INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description, seed_blend) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [name, brand, categoryId, productType, activeIngredients, description, seedBlend ? JSON.stringify(seedBlend) : null] ); const product = result.rows[0]; res.status(201).json({ success: true, message: 'Product created successfully', data: { product: { id: product.id, name: product.name, brand: product.brand, categoryId: product.category_id, productType: product.product_type, activeIngredients: product.active_ingredients, description: product.description, createdAt: product.created_at, seedBlend: product.seed_blend } } }); } catch (error) { next(error); } }); // @route PUT /api/admin/products/:id // @desc Update product // @access Private (Admin) router.put('/products/:id', validateParams(idParamSchema), validateRequest(productSchema), async (req, res, next) => { try { const productId = req.params.id; const { name, brand, categoryId, productType, activeIngredients, description, seedBlend } = req.body; // Check if product exists const productCheck = await pool.query( 'SELECT id FROM products WHERE id = $1', [productId] ); if (productCheck.rows.length === 0) { throw new AppError('Product not found', 404); } // Check if category exists const categoryCheck = await pool.query( 'SELECT id FROM product_categories WHERE id = $1', [categoryId] ); if (categoryCheck.rows.length === 0) { throw new AppError('Product category not found', 404); } const result = await pool.query( `UPDATE products SET name = $1, brand = $2, category_id = $3, product_type = $4, active_ingredients = $5, description = $6, seed_blend = $7 WHERE id = $8 RETURNING *`, [name, brand, categoryId, productType, activeIngredients, description, seedBlend ? JSON.stringify(seedBlend) : null, productId] ); const product = result.rows[0]; res.json({ success: true, message: 'Product updated successfully', data: { product: { id: product.id, name: product.name, brand: product.brand, categoryId: product.category_id, productType: product.product_type, activeIngredients: product.active_ingredients, seedBlend: product.seed_blend, description: product.description, createdAt: product.created_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/admin/products/:id // @desc Delete product // @access Private (Admin) router.delete('/products/:id', validateParams(idParamSchema), async (req, res, next) => { try { const productId = req.params.id; // Check if product exists const productCheck = await pool.query( 'SELECT id, name FROM products WHERE id = $1', [productId] ); if (productCheck.rows.length === 0) { throw new AppError('Product not found', 404); } const product = productCheck.rows[0]; // Check if product is used in any user products or applications const usageCheck = await pool.query( `SELECT (SELECT COUNT(*) FROM user_products WHERE product_id = $1) + (SELECT COUNT(*) FROM application_plan_products WHERE product_id = $1) + (SELECT COUNT(*) FROM application_log_products WHERE product_id = $1) as usage_count`, [productId] ); if (parseInt(usageCheck.rows[0].usage_count) > 0) { throw new AppError('Cannot delete product that is being used by users', 400); } await pool.query('DELETE FROM products WHERE id = $1', [productId]); res.json({ success: true, message: `Product "${product.name}" deleted successfully` }); } catch (error) { next(error); } }); // @route GET /api/admin/system/health // @desc Get system health information // @access Private (Admin) router.get('/system/health', async (req, res, next) => { try { // Database connection test const dbResult = await pool.query('SELECT NOW() as timestamp, version() as version'); // Get database statistics const dbStats = await pool.query(` SELECT pg_size_pretty(pg_database_size(current_database())) as database_size, (SELECT count(*) FROM pg_stat_activity WHERE state = 'active') as active_connections, (SELECT setting FROM pg_settings WHERE name = 'max_connections') as max_connections `); const stats = dbStats.rows[0]; res.json({ success: true, data: { system: { status: 'healthy', timestamp: new Date(), uptime: process.uptime(), nodeVersion: process.version, environment: process.env.NODE_ENV || 'development' }, database: { status: 'connected', version: dbResult.rows[0].version, size: stats.database_size, activeConnections: parseInt(stats.active_connections), maxConnections: parseInt(stats.max_connections), timestamp: dbResult.rows[0].timestamp }, services: { weatherApi: { configured: !!process.env.WEATHER_API_KEY, status: process.env.WEATHER_API_KEY ? 'available' : 'not configured' }, googleOAuth: { configured: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET), status: (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) ? 'available' : 'not configured' } } } }); } catch (error) { next(error); } }); // @route GET /api/admin/products/user // @desc Get all user products for admin management // @access Private (Admin) router.get('/products/user', async (req, res, next) => { try { const { search, category, user_id } = req.query; let whereConditions = []; let queryParams = []; let paramCount = 0; if (search) { paramCount++; whereConditions.push(`(up.custom_name ILIKE $${paramCount} OR up.custom_brand ILIKE $${paramCount} OR u.email ILIKE $${paramCount})`); queryParams.push(`%${search}%`); } if (category) { paramCount++; whereConditions.push(`up.category_id = $${paramCount}`); queryParams.push(category); } if (user_id) { paramCount++; whereConditions.push(`up.user_id = $${paramCount}`); queryParams.push(user_id); } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT up.*, u.email as user_email, u.first_name, u.last_name, pc.name as category_name, p.name as base_product_name, p.brand as base_product_brand, COUNT(pss.id) as spreader_settings_count, COUNT(DISTINCT app.id) as usage_count FROM user_products up JOIN users u ON up.user_id = u.id LEFT JOIN product_categories pc ON up.category_id = pc.id LEFT JOIN products p ON up.product_id = p.id LEFT JOIN product_spreader_settings pss ON up.id = pss.user_product_id LEFT JOIN application_plan_products app ON up.id = app.user_product_id ${whereClause} GROUP BY up.id, u.email, u.first_name, u.last_name, pc.name, p.name, p.brand ORDER BY u.email, up.custom_name `, queryParams); res.json({ success: true, data: { userProducts: result.rows.map(product => ({ id: product.id, userId: product.user_id, userEmail: product.user_email, userName: `${product.first_name} ${product.last_name}`, baseProductId: product.product_id, baseProductName: product.base_product_name, baseProductBrand: product.base_product_brand, customName: product.custom_name, customBrand: product.custom_brand, categoryId: product.category_id, categoryName: product.category_name, productType: product.custom_product_type, activeIngredients: product.custom_active_ingredients, description: product.custom_description, rateAmount: product.custom_rate_amount, rateUnit: product.custom_rate_unit, notes: product.notes, spreaderSettingsCount: parseInt(product.spreader_settings_count), usageCount: parseInt(product.usage_count), createdAt: product.created_at, updatedAt: product.updated_at })) } }); } catch (error) { next(error); } }); // @route POST /api/admin/products/user/:id/promote // @desc Promote user product to shared product // @access Private (Admin) router.post('/products/user/:id/promote', validateParams(idParamSchema), async (req, res, next) => { try { const userProductId = req.params.id; // Get the user product const userProductResult = await pool.query( `SELECT up.*, pc.name as category_name FROM user_products up LEFT JOIN product_categories pc ON up.category_id = pc.id WHERE up.id = $1`, [userProductId] ); if (userProductResult.rows.length === 0) { throw new AppError('User product not found', 404); } const userProduct = userProductResult.rows[0]; // Create new shared product const newProductResult = await pool.query(` INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `, [ userProduct.custom_name, userProduct.custom_brand || 'Generic', userProduct.category_id, userProduct.custom_product_type, userProduct.custom_active_ingredients, userProduct.custom_description ]); const newProduct = newProductResult.rows[0]; // Create default rate if custom rate exists if (userProduct.custom_rate_amount && userProduct.custom_rate_unit) { await pool.query(` INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes) VALUES ($1, $2, $3, $4, $5) `, [ newProduct.id, userProduct.custom_product_type, userProduct.custom_rate_amount, userProduct.custom_rate_unit, `Promoted from user product: ${userProduct.notes || ''}` ]); } res.json({ success: true, message: `User product "${userProduct.custom_name}" promoted to shared product`, data: { newProduct: { id: newProduct.id, name: newProduct.name, brand: newProduct.brand, categoryId: newProduct.category_id, productType: newProduct.product_type, activeIngredients: newProduct.active_ingredients, description: newProduct.description } } }); } catch (error) { next(error); } }); // @route GET /api/admin/equipment/user // @desc Get all user equipment for admin management // @access Private (Admin) router.get('/equipment/user', async (req, res, next) => { try { const { search, category, user_id } = req.query; let whereConditions = []; let queryParams = []; let paramCount = 0; if (search) { paramCount++; whereConditions.push(`(ue.custom_name ILIKE $${paramCount} OR ue.manufacturer ILIKE $${paramCount} OR u.email ILIKE $${paramCount})`); queryParams.push(`%${search}%`); } if (category) { paramCount++; whereConditions.push(`ue.category_id = $${paramCount}`); queryParams.push(category); } if (user_id) { paramCount++; whereConditions.push(`ue.user_id = $${paramCount}`); queryParams.push(user_id); } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; const result = await pool.query(` SELECT ue.*, u.email as user_email, u.first_name, u.last_name, ec.name as category_name, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model FROM user_equipment ue JOIN users u ON ue.user_id = u.id LEFT JOIN equipment_categories ec ON ue.category_id = ec.id LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id ${whereClause} ORDER BY u.email, ue.custom_name `, queryParams); res.json({ success: true, data: { userEquipment: result.rows.map(equipment => ({ id: equipment.id, userId: equipment.user_id, userEmail: equipment.user_email, userName: `${equipment.first_name} ${equipment.last_name}`, equipmentTypeId: equipment.equipment_type_id, categoryId: equipment.category_id, typeName: equipment.type_name, typeManufacturer: equipment.type_manufacturer, typeModel: equipment.type_model, categoryName: equipment.category_name, customName: equipment.custom_name, manufacturer: equipment.manufacturer, model: equipment.model, // Spreader fields capacityLbs: parseFloat(equipment.capacity_lbs) || null, spreaderType: equipment.spreader_type, spreadWidth: parseFloat(equipment.spread_width) || null, // Sprayer fields tankCapacityGallons: parseFloat(equipment.tank_capacity_gallons) || null, sprayerType: equipment.sprayer_type, boomWidth: parseFloat(equipment.boom_width) || null, numberOfNozzles: parseInt(equipment.number_of_nozzles) || null, // Common fields yearManufactured: parseInt(equipment.year_manufactured) || null, serialNumber: equipment.serial_number, notes: equipment.notes, isActive: equipment.is_active, createdAt: equipment.created_at, updatedAt: equipment.updated_at })) } }); } catch (error) { next(error); } }); // @route POST /api/admin/equipment/user/:id/promote // @desc Promote user equipment to shared equipment type // @access Private (Admin) router.post('/equipment/user/:id/promote', validateParams(idParamSchema), async (req, res, next) => { try { const userEquipmentId = req.params.id; // Get the user equipment const userEquipmentResult = await pool.query( `SELECT ue.*, ec.name as category_name FROM user_equipment ue LEFT JOIN equipment_categories ec ON ue.category_id = ec.id WHERE ue.id = $1`, [userEquipmentId] ); if (userEquipmentResult.rows.length === 0) { throw new AppError('User equipment not found', 404); } const userEquipment = userEquipmentResult.rows[0]; // Create new shared equipment type const newEquipmentTypeResult = await pool.query(` INSERT INTO equipment_types (name, manufacturer, model, category_id) VALUES ($1, $2, $3, $4) RETURNING * `, [ userEquipment.custom_name, userEquipment.manufacturer || 'Generic', userEquipment.model || 'Standard', userEquipment.category_id ]); const newEquipmentType = newEquipmentTypeResult.rows[0]; res.json({ success: true, message: `User equipment "${userEquipment.custom_name}" promoted to shared equipment type`, data: { newEquipmentType: { id: newEquipmentType.id, name: newEquipmentType.name, manufacturer: newEquipmentType.manufacturer, model: newEquipmentType.model, categoryId: newEquipmentType.category_id } } }); } catch (error) { next(error); } }); // @route GET /api/admin/products/:id/rates // @desc Get application rates for a specific shared product // @access Private (Admin) router.get('/products/:id/rates', validateParams(idParamSchema), async (req, res, next) => { try { const productId = req.params.id; const result = await pool.query(` SELECT pr.*, p.name as product_name, p.brand as product_brand FROM product_rates pr JOIN products p ON pr.product_id = p.id WHERE pr.product_id = $1 ORDER BY pr.application_type, pr.rate_amount `, [productId]); res.json({ success: true, data: { rates: result.rows.map(rate => ({ id: rate.id, productId: rate.product_id, productName: rate.product_name, productBrand: rate.product_brand, applicationType: rate.application_type, rateAmount: parseFloat(rate.rate_amount), rateUnit: rate.rate_unit, notes: rate.notes, createdAt: rate.created_at })) } }); } catch (error) { next(error); } }); // @route GET /api/admin/products/user/:id/spreader-settings // @desc Get spreader settings for a specific user product // @access Private (Admin) router.get('/products/user/:id/spreader-settings', validateParams(idParamSchema), async (req, res, next) => { try { const userProductId = req.params.id; const result = await pool.query(` SELECT pss.*, ue.custom_name as equipment_name, ue.manufacturer, ue.model as equipment_model FROM product_spreader_settings pss LEFT JOIN user_equipment ue ON pss.equipment_id = ue.id WHERE pss.user_product_id = $1 ORDER BY ue.custom_name NULLS LAST, pss.spreader_brand, pss.spreader_model NULLS LAST, pss.setting_value `, [userProductId]); res.json({ success: true, data: { spreaderSettings: result.rows.map(setting => ({ id: setting.id, userProductId: setting.user_product_id, equipmentId: setting.equipment_id, equipmentName: setting.equipment_name, equipmentManufacturer: setting.manufacturer, equipmentModel: setting.equipment_model, spreaderBrand: setting.spreader_brand, spreaderModel: setting.spreader_model, settingValue: setting.setting_value, rateDescription: setting.rate_description, notes: setting.notes, createdAt: setting.created_at })) } }); } catch (error) { next(error); } }); // @route POST /api/admin/products/user/:id/spreader-settings // @desc Add spreader setting for a user product // @access Private (Admin) router.post('/products/user/:id/spreader-settings', validateParams(idParamSchema), async (req, res, next) => { try { const userProductId = req.params.id; const { equipmentId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes } = req.body; // Verify the user product exists const productCheck = await pool.query( 'SELECT id FROM user_products WHERE id = $1', [userProductId] ); if (productCheck.rows.length === 0) { throw new AppError('User product not found', 404); } const result = await pool.query(` INSERT INTO product_spreader_settings (user_product_id, equipment_id, spreader_brand, spreader_model, setting_value, rate_description, notes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [userProductId, equipmentId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes]); const setting = result.rows[0]; res.status(201).json({ success: true, message: 'Spreader setting added successfully', data: { spreaderSetting: { id: setting.id, userProductId: setting.user_product_id, equipmentId: setting.equipment_id, spreaderBrand: setting.spreader_brand, spreaderModel: setting.spreader_model, settingValue: setting.setting_value, rateDescription: setting.rate_description, notes: setting.notes, createdAt: setting.created_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/admin/products/user/spreader-settings/:id // @desc Delete spreader setting // @access Private (Admin) router.delete('/products/user/spreader-settings/:id', validateParams(idParamSchema), async (req, res, next) => { try { const settingId = req.params.id; // Check if setting exists const settingCheck = await pool.query( 'SELECT id FROM product_spreader_settings WHERE id = $1', [settingId] ); if (settingCheck.rows.length === 0) { throw new AppError('Spreader setting not found', 404); } await pool.query('DELETE FROM product_spreader_settings WHERE id = $1', [settingId]); res.json({ success: true, message: 'Spreader setting deleted successfully' }); } catch (error) { next(error); } }); // @route GET /api/admin/settings // @desc Get admin settings // @access Private (Admin) router.get('/settings', async (req, res, next) => { try { // For now, we'll store settings in the database. Let's create a simple settings table approach // First check if settings table exists, if not create it 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 all settings const settingsResult = await pool.query('SELECT setting_key, setting_value FROM admin_settings'); const settings = {}; settingsResult.rows.forEach(row => { // Convert string values to appropriate types if (row.setting_value === 'true' || row.setting_value === 'false') { settings[row.setting_key] = row.setting_value === 'true'; } else { settings[row.setting_key] = row.setting_value; } }); res.json({ success: true, data: settings }); } catch (error) { next(error); } }); // @route PUT /api/admin/settings // @desc Update admin settings // @access Private (Admin) router.put('/settings', async (req, res, next) => { try { const updates = req.body; // Update each setting for (const [key, value] of Object.entries(updates)) { const stringValue = typeof value === 'boolean' ? value.toString() : value; await pool.query(` INSERT INTO admin_settings (setting_key, setting_value, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (setting_key) DO UPDATE SET setting_value = $2, updated_at = CURRENT_TIMESTAMP `, [key, stringValue]); } res.json({ success: true, message: 'Settings updated successfully', data: updates }); } catch (error) { next(error); } }); module.exports = router; // ----- Properties management ----- // List properties (with user info) router.get('/properties', async (req, res, next) => { try { const { search, user_id } = req.query; let where = []; let params = []; let n = 0; if (user_id) { n++; where.push(`p.user_id=$${n}`); params.push(user_id); } if (search) { n++; where.push(`(p.name ILIKE $${n} OR p.address ILIKE $${n} OR u.email ILIKE $${n})`); params.push(`%${search}%`); } const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; const rs = await pool.query(` SELECT p.*, u.email, u.first_name, u.last_name, COUNT(ls.id) as section_count, COALESCE(SUM(ls.area),0) as calculated_area FROM properties p JOIN users u ON p.user_id=u.id LEFT JOIN lawn_sections ls ON ls.property_id=p.id ${whereClause} GROUP BY p.id, u.email, u.first_name, u.last_name ORDER BY p.created_at DESC `, params); res.json({ success: true, data: { properties: rs.rows.map(r=>({ id: r.id, userId: r.user_id, userEmail: r.email, userName: `${r.first_name} ${r.last_name}`, name: r.name, address: r.address, latitude: parseFloat(r.latitude), longitude: parseFloat(r.longitude), totalArea: parseFloat(r.total_area), calculatedArea: parseFloat(r.calculated_area), sectionCount: parseInt(r.section_count), createdAt: r.created_at, updatedAt: r.updated_at })) }}); } catch (e) { next(e); } }); // Get property with sections (admin override) router.get('/properties/:id', validateParams(idParamSchema), async (req, res, next) => { try { const { id } = req.params; const pr = await pool.query(`SELECT p.*, u.email FROM properties p JOIN users u ON p.user_id=u.id WHERE p.id=$1`, [id]); if (pr.rows.length===0) throw new AppError('Property not found', 404); const srs = await pool.query(`SELECT * FROM lawn_sections WHERE property_id=$1 ORDER BY name`, [id]); const p = pr.rows[0]; res.json({ success:true, data:{ property: { id: p.id, userId: p.user_id, userEmail: p.email, name: p.name, address: p.address, latitude: parseFloat(p.latitude), longitude: parseFloat(p.longitude), totalArea: parseFloat(p.total_area), createdAt: p.created_at, updatedAt: p.updated_at, sections: srs.rows.map(s=>({ id:s.id, name:s.name, area: parseFloat(s.area), polygonData: s.polygon_data, grassType: s.grass_type, grassTypes: s.grass_types, captureMethod: s.capture_method, captureMeta: s.capture_meta, createdAt:s.created_at, updatedAt:s.updated_at })) }}}); } catch (e) { next(e); } }); // Update a lawn section (admin override) router.put('/properties/:propertyId/sections/:sectionId', validateParams(idParamSchema), async (req, res, next) => { try { const { propertyId, sectionId } = req.params; const { name, area, polygonData, grassType, grassTypes, captureMethod, captureMeta } = req.body; const own = await pool.query('SELECT id FROM properties WHERE id=$1', [propertyId]); if (own.rows.length===0) throw new AppError('Property not found', 404); // optional sanitize lite let poly = polygonData; try { const coords = polygonData?.coordinates?.[0] || []; let filtered = coords.filter(Boolean); if (filtered.length>=3) poly = { ...polygonData, coordinates: [filtered] }; } catch {} const rs = await pool.query(`UPDATE lawn_sections SET name=$1, area=$2, polygon_data=$3, grass_type=$4, grass_types=$5, capture_method=$6, capture_meta=$7, updated_at=CURRENT_TIMESTAMP WHERE id=$8 AND property_id=$9 RETURNING *`, [ name, area, JSON.stringify(poly), grassType || (Array.isArray(grassTypes)? grassTypes.join(', '): null), grassTypes? JSON.stringify(grassTypes): null, captureMethod||null, captureMeta? JSON.stringify(captureMeta): null, sectionId, propertyId ]); if (rs.rows.length===0) throw new AppError('Section not found', 404); const s=rs.rows[0]; res.json({ success:true, data:{ section:{ id:s.id, name:s.name, area: parseFloat(s.area), polygonData: s.polygon_data, grassType: s.grass_type, grassTypes: s.grass_types, captureMethod: s.capture_method, createdAt:s.created_at, updatedAt:s.updated_at }}}); } catch (e) { next(e); } });