const express = require('express'); const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); const { propertySchema, lawnSectionSchema, idParamSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); // Geometry helpers for sanitization and simplification are defined below const router = express.Router(); // 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); return 0; } console.log('Calculating area for coordinates:', coordinates); let area = 0; const n = coordinates.length; for (let i = 0; i < n; i++) { const j = (i + 1) % n; area += coordinates[i][1] * coordinates[j][0]; area -= coordinates[j][1] * coordinates[i][0]; } area = Math.abs(area) / 2; console.log('Raw shoelace area (in deg²):', area); const avgLat = coordinates.reduce((sum, coord) => sum + coord[0], 0) / n; console.log('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); return areaInSquareFeet; }; // ---- Geometry sanitization & simplification helpers ---- const toMeters = (lat1, lng1, lat2, lng2) => { const toRad = (d)=> (d*Math.PI)/180; const R=6371000; const dLat=toRad(lat2-lat1), dLng=toRad(lng2-lng1); const a=Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2; return 2*R*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); }; const sanitizePolygon = (coords) => { const MIN_MOVE_M = 0.5; const COLINEAR_EPS_DEG = 3; if (!Array.isArray(coords) || coords.length < 3) return coords || []; let filtered = [coords[0]]; for (let i=1;i= MIN_MOVE_M) filtered.push(coords[i]); } const angleDeg = (a,b,c) => { const v1=[b[0]-a[0], b[1]-a[1]]; const v2=[c[0]-b[0], c[1]-b[1]]; const dot=v1[0]*v2[0]+v1[1]*v2[1]; const m1=Math.hypot(v1[0],v1[1]); const m2=Math.hypot(v2[0],v2[1]); if (m1===0||m2===0) return 180; const cos= Math.min(1, Math.max(-1, dot/(m1*m2))); return Math.acos(cos)*180/Math.PI; }; if (filtered.length > 3) { const out=[filtered[0]]; for (let i=1;i= COLINEAR_EPS_DEG) out.push(b); } out.push(filtered[filtered.length-1]); filtered = out; } // RDP simplification with small epsilon (~2m in lat/long degrees) const EPS_DEG = 0.00002; const perpDist = (p, a, b) => { const [x0,y0]=p, [x1,y1]=a, [x2,y2]=b; const num = Math.abs((y2-y1)*x0 - (x2-x1)*y0 + x2*y1 - y2*x1); const den = Math.hypot(y2-y1, x2-x1) || 1e-9; return num/den; }; const rdp = (pts, eps) => { if (pts.length < 3) return pts; let dmax=0, idx=0; for (let i=1;idmax){ dmax=d; idx=i; } } if (dmax>eps){ const res1 = rdp(pts.slice(0, idx+1), eps); const res2 = rdp(pts.slice(idx), eps); return res1.slice(0, -1).concat(res2); } else { return [pts[0], pts[pts.length-1]]; } }; try { filtered = rdp(filtered, EPS_DEG); } catch {} // Ensure at least 3 unique points remain; if not, fall back to original coords const unique = []; for (const p of filtered) { if (!unique.some(q => Math.abs(q[0]-p[0])<1e-9 && Math.abs(q[1]-p[1])<1e-9)) unique.push(p); } if (unique.length < 3) return coords; return unique; }; // @route GET /api/properties // @desc Get all properties for current user // @access Private router.get('/', async (req, res, next) => { try { const result = await pool.query( `SELECT p.*, COUNT(ls.id) as section_count, COALESCE(SUM(ls.area), 0) as calculated_area FROM properties p LEFT JOIN lawn_sections ls ON p.id = ls.property_id WHERE p.user_id = $1 GROUP BY p.id ORDER BY p.created_at DESC`, [req.user.id] ); res.json({ success: true, data: { properties: result.rows.map(row => ({ id: row.id, name: row.name, address: row.address, latitude: parseFloat(row.latitude), longitude: parseFloat(row.longitude), totalArea: parseFloat(row.total_area), calculatedArea: parseFloat(row.calculated_area), sectionCount: parseInt(row.section_count), createdAt: row.created_at, updatedAt: row.updated_at })) } }); } catch (error) { next(error); } }); // @route GET /api/properties/:id // @desc Get single property with sections // @access Private router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const propertyId = req.params.id; // Get property const propertyResult = await pool.query( 'SELECT * FROM properties WHERE id = $1 AND user_id = $2', [propertyId, req.user.id] ); if (propertyResult.rows.length === 0) { throw new AppError('Property not found', 404); } const property = propertyResult.rows[0]; // Get lawn sections const sectionsResult = await pool.query( 'SELECT *, grass_types FROM lawn_sections WHERE property_id = $1 ORDER BY name', [propertyId] ); res.json({ success: true, data: { property: { id: property.id, name: property.name, address: property.address, latitude: parseFloat(property.latitude), longitude: parseFloat(property.longitude), totalArea: parseFloat(property.total_area), createdAt: property.created_at, updatedAt: property.updated_at, sections: sectionsResult.rows.map(section => ({ id: section.id, name: section.name, area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, grassTypes: section.grass_types || null, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at })) } } }); } catch (error) { next(error); } }); // @route POST /api/properties // @desc Create new property // @access Private router.post('/', validateRequest(propertySchema), async (req, res, next) => { try { const { name, address, latitude, longitude, totalArea } = req.body; const result = await pool.query( `INSERT INTO properties (user_id, name, address, latitude, longitude, total_area) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [req.user.id, name, address, latitude, longitude, totalArea] ); const property = result.rows[0]; res.status(201).json({ success: true, message: 'Property created successfully', data: { property: { id: property.id, name: property.name, address: property.address, latitude: parseFloat(property.latitude), longitude: parseFloat(property.longitude), totalArea: parseFloat(property.total_area), createdAt: property.created_at, updatedAt: property.updated_at } } }); } catch (error) { next(error); } }); // @route PUT /api/properties/:id // @desc Update property // @access Private router.put('/:id', validateParams(idParamSchema), validateRequest(propertySchema), async (req, res, next) => { try { const propertyId = req.params.id; const { name, address, latitude, longitude, totalArea } = req.body; // Check if property exists and belongs to user const checkResult = await pool.query( 'SELECT id FROM properties WHERE id = $1 AND user_id = $2', [propertyId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Property not found', 404); } const result = await pool.query( `UPDATE properties SET name = $1, address = $2, latitude = $3, longitude = $4, total_area = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $6 RETURNING *`, [name, address, latitude, longitude, totalArea, propertyId] ); const property = result.rows[0]; res.json({ success: true, message: 'Property updated successfully', data: { property: { id: property.id, name: property.name, address: property.address, latitude: parseFloat(property.latitude), longitude: parseFloat(property.longitude), totalArea: parseFloat(property.total_area), createdAt: property.created_at, updatedAt: property.updated_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/properties/:id // @desc Delete property // @access Private router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const propertyId = req.params.id; // Check if property exists and belongs to user const checkResult = await pool.query( 'SELECT id FROM properties WHERE id = $1 AND user_id = $2', [propertyId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Property not found', 404); } // Check for active applications (planned/in_progress) linked to any sections on this property const activeApps = await pool.query( `SELECT COUNT(*) as count FROM application_plans ap JOIN application_plan_sections aps ON ap.id = aps.plan_id JOIN lawn_sections ls ON aps.lawn_section_id = ls.id WHERE ls.property_id = $1 AND ap.status IN ('planned', 'in_progress')`, [propertyId] ); if (parseInt(activeApps.rows[0].count) > 0) { throw new AppError('Cannot delete property with active applications', 400); } // Historical logs are preserved by FK ON DELETE SET NULL; no hard block here await pool.query('DELETE FROM properties WHERE id = $1', [propertyId]); res.json({ success: true, message: 'Property deleted successfully' }); } catch (error) { next(error); } }); // @route POST /api/properties/:id/sections // @desc Create lawn section for property // @access Private router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => { try { const propertyId = req.params.id; const { name, area, polygonData, grassType, grassTypes, soilType, captureMethod, captureMeta } = req.body; // Check if property exists and belongs to user const propertyResult = await pool.query( 'SELECT id FROM properties WHERE id = $1 AND user_id = $2', [propertyId, req.user.id] ); if (propertyResult.rows.length === 0) { throw new AppError('Property not found', 404); } // 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]); calculatedArea = calculatePolygonArea(polygonData.coordinates[0]); console.log('Calculated area:', calculatedArea); } // Sanitize polygon before storing let poly = polygonData; try { const ring = polygonData?.coordinates?.[0] || []; // For GPS-captured shapes, keep the user's ring as-is to avoid over-simplifying if (captureMethod === 'gps_points' || captureMethod === 'gps_trace') { poly = { ...polygonData, coordinates: [ring] }; } else { const cleaned = sanitizePolygon(ring); poly = { ...polygonData, coordinates: [cleaned && cleaned.length >= 3 ? cleaned : ring] }; } } catch {} const result = await pool.query( `INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, grass_types, soil_type, capture_method, capture_meta) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ propertyId, name, calculatedArea, JSON.stringify(poly), grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null), grassTypes ? JSON.stringify(grassTypes) : null, soilType, captureMethod || null, captureMeta ? JSON.stringify(captureMeta) : null ] ); const section = result.rows[0]; res.status(201).json({ success: true, message: 'Lawn section created successfully', data: { section: { id: section.id, name: section.name, area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, grassTypes: section.grass_types, captureMethod: section.capture_method, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at } } }); } catch (error) { next(error); } }); // @route PUT /api/properties/:propertyId/sections/:sectionId // @desc Update lawn section // @access Private router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { try { const { propertyId, sectionId } = req.params; const { name, area, polygonData, grassType, grassTypes, soilType, captureMethod, captureMeta } = req.body; // Check if section exists and user owns the property const checkResult = await pool.query( `SELECT ls.id FROM lawn_sections ls JOIN properties p ON ls.property_id = p.id WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`, [sectionId, propertyId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Lawn section not found', 404); } // Calculate area from polygon if provided let calculatedArea = area; if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) { calculatedArea = calculatePolygonArea(polygonData.coordinates[0]); } let upoly = polygonData; try { const ring = polygonData?.coordinates?.[0] || []; if (captureMethod === 'gps_points' || captureMethod === 'gps_trace') { upoly = { ...polygonData, coordinates: [ring] }; } else { const cleaned = sanitizePolygon(ring); upoly = { ...polygonData, coordinates: [cleaned && cleaned.length >= 3 ? cleaned : ring] }; } } catch {} const result = await pool.query( `UPDATE lawn_sections SET name = $1, area = $2, polygon_data = $3, grass_type = $4, grass_types=$5, soil_type = $6, capture_method=$7, capture_meta=$8, updated_at = CURRENT_TIMESTAMP WHERE id = $9 RETURNING *`, [ name, calculatedArea, JSON.stringify(upoly), grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null), grassTypes ? JSON.stringify(grassTypes) : null, soilType, captureMethod || null, captureMeta ? JSON.stringify(captureMeta) : null, sectionId ] ); const section = result.rows[0]; res.json({ success: true, message: 'Lawn section updated successfully', data: { section: { id: section.id, name: section.name, area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, grassTypes: section.grass_types, captureMethod: section.capture_method, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/properties/:propertyId/sections/:sectionId // @desc Delete lawn section // @access Private router.delete('/:propertyId/sections/:sectionId', async (req, res, next) => { try { const { propertyId, sectionId } = req.params; // Check if section exists and user owns the property const checkResult = await pool.query( `SELECT ls.id FROM lawn_sections ls JOIN properties p ON ls.property_id = p.id WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`, [sectionId, propertyId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Lawn section not found', 404); } // Check for active applications that include this section const activeApps = await pool.query( `SELECT COUNT(*) as count FROM application_plans ap JOIN application_plan_sections aps ON ap.id = aps.plan_id WHERE aps.lawn_section_id = $1 AND ap.status IN ('planned', 'in_progress')`, [sectionId] ); if (parseInt(activeApps.rows[0].count) > 0) { throw new AppError('Cannot delete section with active applications', 400); } // Historical logs are preserved by FK ON DELETE SET NULL; proceed with delete await pool.query('DELETE FROM lawn_sections WHERE id = $1', [sectionId]); res.json({ success: true, message: 'Lawn section deleted successfully' }); } catch (error) { next(error); } }); module.exports = router;