537 lines
17 KiB
JavaScript
537 lines
17 KiB
JavaScript
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<coords.length;i++){
|
|
const [pl,pg]=filtered[filtered.length-1];
|
|
const [cl,cg]=coords[i];
|
|
if (toMeters(pl,pg,cl,cg) >= 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<filtered.length-1;i++){
|
|
const a=filtered[i-1], b=filtered[i], c=filtered[i+1];
|
|
if (angleDeg(a,b,c) >= 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;i<pts.length-1;i++){
|
|
const d = perpDist(pts[i], pts[0], pts[pts.length-1]);
|
|
if (d>dmax){ 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;
|