Files
turftracker/backend/src/routes/admin.js
2025-09-04 07:29:41 -05:00

1196 lines
40 KiB
JavaScript

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); }
});