import express from 'express'; import cors from 'cors'; import { createClient } from '@libsql/client'; import multer from 'multer'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import cron from 'node-cron'; import { checkMowingReminders, sendWeeklyReport, testTelegramConnection } from './telegram.js'; import dotenv from 'dotenv'; dotenv.config({ path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; // Middleware app.use(cors()); app.use(express.json()); // Serve static files in production if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, '../public'))); } // Serve uploads app.use('/uploads', express.static('uploads')); // Ensure uploads directory exists const uploadsDir = 'uploads'; if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir); } // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); } }); const upload = multer({ storage: storage, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed!'), false); } }, limits: { fileSize: 5 * 1024 * 1024 // 5MB limit } }); // Initialize libsql database const db = createClient({ url: 'file:db/lawn_scheduler.db' }); // Create mowers table await db.execute(` CREATE TABLE IF NOT EXISTS mowers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL, brand TEXT, model TEXT, isActive BOOLEAN DEFAULT 1, createdAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Create zones table with updated schema await db.execute(` CREATE TABLE IF NOT EXISTS zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, imagePath TEXT, lastMowedDate TEXT, intervalDays INTEGER, nextMowDate TEXT, scheduleType TEXT DEFAULT 'interval', area REAL DEFAULT 0, createdAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Create mowing history table await db.execute(` CREATE TABLE IF NOT EXISTS mowing_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, zoneId INTEGER NOT NULL, mowerId INTEGER, mowedDate TEXT NOT NULL, notes TEXT, duration INTEGER, -- in minutes weather TEXT, activityType TEXT DEFAULT 'mowing', -- 'mowing' or 'trimming' sessionId TEXT, -- for grouping bulk mowing sessions createdAt TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE, FOREIGN KEY (mowerId) REFERENCES mowers (id) ON DELETE SET NULL ) `); // Create season settings table await db.execute(` CREATE TABLE IF NOT EXISTS season_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, startMonth INTEGER NOT NULL DEFAULT 5, startDay INTEGER NOT NULL DEFAULT 1, endMonth INTEGER NOT NULL DEFAULT 9, endDay INTEGER NOT NULL DEFAULT 30, isActive BOOLEAN DEFAULT 1, createdAt TEXT DEFAULT CURRENT_TIMESTAMP, updatedAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Insert default season settings if table is empty const seasonCountResult = await db.execute('SELECT COUNT(*) as count FROM season_settings'); const seasonCount = seasonCountResult.rows[0].count; if (seasonCount === 0) { await db.execute({ sql: 'INSERT INTO season_settings (startMonth, startDay, endMonth, endDay, isActive) VALUES (?, ?, ?, ?, ?)', args: [5, 1, 9, 30, 1] // May 1 to September 30, active }); } // Add new columns to existing zones table if they don't exist try { await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`); } catch (error) { // Column already exists, ignore error } try { await db.execute(`ALTER TABLE zones ADD COLUMN scheduleType TEXT DEFAULT 'interval'`); } catch (error) { // Column already exists, ignore error } // Add area column to existing table if it doesn't exist try { await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`); } catch (error) { // Column already exists, ignore error } // Add mowerId column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN mowerId INTEGER REFERENCES mowers (id) ON DELETE SET NULL`); } catch (error) { // Column already exists, ignore error } // Add sessionId column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN sessionId TEXT`); } catch (error) { // Column already exists, ignore error } // Add activityType column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN activityType TEXT DEFAULT 'mowing'`); } catch (error) { // Column already exists, ignore error } // Insert default mowers if table is empty const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers'); const mowerCount = mowerCountResult.rows[0].count; if (mowerCount === 0) { await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: ['Battery Mower', 'Battery', 'Generic', 'Battery Model'] }); await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: ['Electric Mower', 'Electric', 'Generic', 'Electric Model'] }); } // Insert sample data if table is empty const countResult = await db.execute('SELECT COUNT(*) as count FROM zones'); const count = countResult.rows[0].count; if (count === 0) { const today = new Date(); const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000); // Get mower IDs for sample data const mowersResult = await db.execute('SELECT id FROM mowers LIMIT 2'); const batteryMowerId = mowersResult.rows[0]?.id || 1; const electricMowerId = mowersResult.rows[1]?.id || 2; // Insert zones with different schedule types const frontYardResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Front Yard', weekAgo.toISOString(), 7, 'interval', 150.5] }); const backYardResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 'interval', 280.0] }); const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); const sideGardenResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Side Garden', today.toISOString(), nextWeek.toISOString(), 'specific', 75.25] }); // Insert sample mowing history with mower assignments const sampleHistory = [ { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: today, duration: 45, weather: 'Sunny', notes: 'Perfect mowing conditions today' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: weekAgo, duration: 40, weather: 'Cloudy', notes: 'Grass was a bit wet from morning dew' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), duration: 60, weather: 'Partly cloudy', notes: 'Trimmed edges and cleaned up leaves' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000), duration: 25, weather: 'Sunny', notes: 'Quick touch-up, looks great' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), duration: 50, weather: 'Overcast', notes: 'First mow of the season' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 16 * 24 * 60 * 60 * 1000), duration: 65, weather: 'Sunny', notes: 'Had to go slow due to thick growth' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 17 * 24 * 60 * 60 * 1000), duration: 30, weather: 'Windy', notes: 'Challenging conditions but got it done' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 21 * 24 * 60 * 60 * 1000), duration: 42, weather: 'Cool', notes: 'Nice cool morning for mowing' }, { zoneId: backYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000), duration: 55, weather: 'Humid', notes: 'Very humid day, took frequent breaks' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 31 * 24 * 60 * 60 * 1000), duration: 28, weather: 'Sunny', notes: 'Beautiful day for yard work' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 35 * 24 * 60 * 60 * 1000), duration: 38, weather: 'Partly cloudy', notes: 'Standard weekly maintenance' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 44 * 24 * 60 * 60 * 1000), duration: 70, weather: 'Hot', notes: 'Very hot day, started early morning' }, ]; for (const history of sampleHistory) { await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, duration, weather, notes) VALUES (?, ?, ?, ?, ?, ?)', args: [history.zoneId, history.mowerId, history.date.toISOString(), history.duration, history.weather, history.notes] }); } } // Helper function to check if we're currently in mowing season async function isInMowingSeason() { try { const result = await db.execute('SELECT * FROM season_settings WHERE id = 1'); if (result.rows.length === 0) return true; // Default to always in season if no settings const settings = result.rows[0]; if (!settings.isActive) return true; // If season is disabled, always in season const now = new Date(); const currentMonth = now.getMonth() + 1; const currentDay = now.getDate(); const startDate = new Date(now.getFullYear(), settings.startMonth - 1, settings.startDay); const endDate = new Date(now.getFullYear(), settings.endMonth - 1, settings.endDay); const currentDate = new Date(now.getFullYear(), currentMonth - 1, currentDay); if (startDate <= endDate) { // Same year season (e.g., May to September) return currentDate >= startDate && currentDate <= endDate; } else { // Cross-year season (e.g., November to March) return currentDate >= startDate || currentDate <= endDate; } } catch (error) { console.error('Error checking mowing season:', error); return true; // Default to in season on error } } // Helper function to calculate zone status async function calculateZoneStatus(zone) { // First check if we're in mowing season const inSeason = await isInMowingSeason(); if (!inSeason) { return { ...zone, daysSinceLastMow: null, daysUntilNext: null, status: 'off-season', isOverdue: false, isDueToday: false, isNew: false, isOffSeason: true }; } const today = new Date(); // For new zones that haven't been mowed yet if (!zone.lastMowedDate) { return { ...zone, daysSinceLastMow: null, daysUntilNext: null, status: 'new', isOverdue: false, isDueToday: false, isNew: true, isOffSeason: false }; } const lastMowed = new Date(zone.lastMowedDate); const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24)); let daysUntilNext; let status = 'ok'; // Always check for nextMowDate first (this could be set by trimming or specific scheduling) if (zone.nextMowDate) { // Specific date scheduling const nextMowDate = new Date(zone.nextMowDate); daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24)); } else if (zone.scheduleType === 'interval' && zone.intervalDays) { // Interval-based scheduling daysUntilNext = zone.intervalDays - daysSinceLastMow; } else { // No valid scheduling information daysUntilNext = null; } if (daysUntilNext !== null && daysUntilNext < 0) { status = 'overdue'; } else if (daysUntilNext !== null && daysUntilNext <= 0) { status = 'due'; } else if (daysUntilNext !== null && daysUntilNext <= 1) { status = 'due'; } return { ...zone, daysSinceLastMow, daysUntilNext, status, isOverdue: daysUntilNext !== null && daysUntilNext < 0, isDueToday: daysUntilNext !== null && daysUntilNext <= 0 && daysUntilNext >= -1, isNew: false, isOffSeason: false }; } // Helper function to calculate next mow date for interval scheduling function calculateNextMowDate(lastMowedDate, intervalDays) { if (!lastMowedDate || !intervalDays) return null; const lastMowed = new Date(lastMowedDate); const nextMow = new Date(lastMowed.getTime() + (intervalDays * 24 * 60 * 60 * 1000)); return nextMow.toISOString(); } // Helper function to generate session ID function generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Mower routes app.get('/api/mowers', async (req, res) => { try { const result = await db.execute('SELECT * FROM mowers WHERE isActive = 1 ORDER BY name'); const mowers = result.rows.map(row => ({ id: row.id, name: row.name, type: row.type, brand: row.brand, model: row.model, isActive: row.isActive, createdAt: row.createdAt })); res.json(mowers); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/mowers', async (req, res) => { try { const { name, type, brand, model } = req.body; const result = await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: [name, type, brand || null, model || null] }); const newMowerResult = await db.execute({ sql: 'SELECT * FROM mowers WHERE id = ?', args: [result.lastInsertRowid] }); const newMower = { id: newMowerResult.rows[0].id, name: newMowerResult.rows[0].name, type: newMowerResult.rows[0].type, brand: newMowerResult.rows[0].brand, model: newMowerResult.rows[0].model, isActive: newMowerResult.rows[0].isActive, createdAt: newMowerResult.rows[0].createdAt }; res.status(201).json(newMower); } catch (error) { res.status(500).json({ error: error.message }); } }); // Routes app.get('/api/zones', async (req, res) => { try { const result = await db.execute('SELECT * FROM zones ORDER BY name'); const zones = result.rows.map(row => ({ id: row.id, name: row.name, imagePath: row.imagePath, lastMowedDate: row.lastMowedDate, intervalDays: row.intervalDays, nextMowDate: row.nextMowDate, scheduleType: row.scheduleType || 'interval', area: row.area || 0, createdAt: row.createdAt })); const zonesWithStatus = await Promise.all(zones.map(calculateZoneStatus)); res.json(zonesWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/zones/:id', async (req, res) => { try { const result = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (result.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = { id: result.rows[0].id, name: result.rows[0].name, imagePath: result.rows[0].imagePath, lastMowedDate: result.rows[0].lastMowedDate, intervalDays: result.rows[0].intervalDays, nextMowDate: result.rows[0].nextMowDate, scheduleType: result.rows[0].scheduleType || 'interval', area: result.rows[0].area || 0, createdAt: result.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(zone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get mowing history for all zones or specific zone with pagination app.get('/api/history', async (req, res) => { try { const { zoneId, limit = 10, offset = 0 } = req.query; // Get total count for pagination let countSql = ` SELECT COUNT(*) as total FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id LEFT JOIN mowers m ON mh.mowerId = m.id `; let countArgs = []; if (zoneId) { countSql += ' WHERE mh.zoneId = ?'; countArgs.push(zoneId); } const countResult = await db.execute({ sql: countSql, args: countArgs }); const total = countResult.rows[0].total; // Get paginated results let sql = ` SELECT mh.*, z.name as zoneName, z.area as zoneArea, m.name as mowerName, m.type as mowerType FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id LEFT JOIN mowers m ON mh.mowerId = m.id `; let args = []; if (zoneId) { sql += ' WHERE mh.zoneId = ?'; args.push(zoneId); } sql += ' ORDER BY mh.mowedDate DESC LIMIT ? OFFSET ?'; args.push(parseInt(limit), parseInt(offset)); const result = await db.execute({ sql, args }); const history = result.rows.map(row => ({ id: row.id, zoneId: row.zoneId, zoneName: row.zoneName, zoneArea: row.zoneArea, mowerId: row.mowerId, mowerName: row.mowerName, mowerType: row.mowerType, mowedDate: row.mowedDate, notes: row.notes, duration: row.duration, weather: row.weather, activityType: row.activityType || 'mowing', sessionId: row.sessionId, createdAt: row.createdAt })); // Calculate pagination info const currentPage = Math.floor(parseInt(offset) / parseInt(limit)) + 1; const totalPages = Math.ceil(total / parseInt(limit)); const hasNextPage = currentPage < totalPages; const hasPrevPage = currentPage > 1; res.json({ data: history, pagination: { total, currentPage, totalPages, hasNextPage, hasPrevPage, limit: parseInt(limit), offset: parseInt(offset) } }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get mowing statistics app.get('/api/history/stats', async (req, res) => { try { const { period = '30' } = req.query; // days const periodDays = parseInt(period); const startDate = new Date(); startDate.setDate(startDate.getDate() - periodDays); // Total mowing sessions const totalResult = await db.execute({ sql: ` SELECT COUNT(*) as total FROM ( SELECT 1 FROM mowing_history WHERE mowedDate >= ? AND sessionId IS NULL UNION ALL SELECT 1 FROM mowing_history WHERE mowedDate >= ? AND sessionId IS NOT NULL GROUP BY sessionId ) AS combined `, args: [startDate.toISOString(), startDate.toISOString()] }); // Total time spent const timeResult = await db.execute({ sql: 'SELECT SUM(duration) as totalMinutes FROM mowing_history WHERE mowedDate >= ? AND duration IS NOT NULL', args: [startDate.toISOString()] }); // Total area mowed const areaResult = await db.execute({ sql: ` SELECT SUM(z.area) as totalArea FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mh.mowedDate >= ? `, args: [startDate.toISOString()] }); // Most active zone const activeZoneResult = await db.execute({ sql: ` SELECT z.name, COUNT(*) as sessions FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mh.mowedDate >= ? GROUP BY mh.zoneId, z.name ORDER BY sessions DESC LIMIT 1 `, args: [startDate.toISOString()] }); // Most used mower const activeMowerResult = await db.execute({ sql: ` SELECT m.name, m.type, COUNT(*) as sessions FROM mowing_history mh JOIN mowers m ON mh.mowerId = m.id WHERE mh.mowedDate >= ? GROUP BY mh.mowerId, m.name, m.type ORDER BY sessions DESC LIMIT 1 `, args: [startDate.toISOString()] }); // Daily activity for chart const dailyResult = await db.execute({ sql: ` SELECT DATE(mowedDate) as date, COUNT(*) as sessions, SUM(duration) as totalDuration, SUM(z.area) as totalArea FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mowedDate >= ? GROUP BY DATE(mowedDate) ORDER BY date DESC LIMIT 30 `, args: [startDate.toISOString()] }); res.json({ period: periodDays, totalSessions: totalResult.rows[0].total, totalMinutes: timeResult.rows[0].totalMinutes || 0, totalArea: areaResult.rows[0].totalArea || 0, mostActiveZone: activeZoneResult.rows[0] || null, mostUsedMower: activeMowerResult.rows[0] || null, dailyActivity: dailyResult.rows.map(row => ({ date: row.date, sessions: row.sessions, duration: row.totalDuration || 0, area: row.totalArea || 0 })) }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/zones', upload.single('image'), async (req, res) => { try { const { name, intervalDays, nextMowDate, scheduleType, area } = req.body; const imagePath = req.file ? `/uploads/${req.file.filename}` : null; // For new zones, don't set lastMowedDate (they haven't been mowed yet) let sql, args; if (scheduleType === 'specific') { sql = 'INSERT INTO zones (name, imagePath, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; args = [name, imagePath, nextMowDate, scheduleType, parseFloat(area) || 0]; } else { sql = 'INSERT INTO zones (name, imagePath, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; args = [name, imagePath, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0]; } const result = await db.execute({ sql, args }); const newZoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [result.lastInsertRowid] }); const newZone = { id: newZoneResult.rows[0].id, name: newZoneResult.rows[0].name, imagePath: newZoneResult.rows[0].imagePath, lastMowedDate: newZoneResult.rows[0].lastMowedDate, intervalDays: newZoneResult.rows[0].intervalDays, nextMowDate: newZoneResult.rows[0].nextMowDate, scheduleType: newZoneResult.rows[0].scheduleType || 'interval', area: newZoneResult.rows[0].area || 0, createdAt: newZoneResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(newZone); res.status(201).json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/zones/:id', upload.single('image'), async (req, res) => { try { const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body; const existingResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (existingResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const existingZone = existingResult.rows[0]; const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath; let sql, args; if (scheduleType === 'specific') { sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, nextMowDate = ?, scheduleType = ?, intervalDays = NULL, area = ? WHERE id = ?'; args = [name, imagePath, lastMowedDate || null, nextMowDate, scheduleType, parseFloat(area) || 0, req.params.id]; } else { sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, scheduleType = ?, nextMowDate = NULL, area = ? WHERE id = ?'; args = [name, imagePath, lastMowedDate || null, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0, req.params.id]; } console.log('Updating zone with data:', { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area }); console.log('SQL:', sql); console.log('ARGS:', args); await db.execute({ sql, args }); // Delete old image if new one was provided if (req.file && existingZone.imagePath) { const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1)); if (fs.existsSync(oldImagePath)) { fs.unlinkSync(oldImagePath); } } const updatedResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); const updatedZone = { id: updatedResult.rows[0].id, name: updatedResult.rows[0].name, imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, nextMowDate: updatedResult.rows[0].nextMowDate, scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = await calculateZoneStatus(zone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/zones/:id', async (req, res) => { try { const result = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (result.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = result.rows[0]; // Delete associated image if (zone.imagePath) { const imagePath = path.join(process.cwd(), zone.imagePath.substring(1)); if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } } await db.execute({ sql: 'DELETE FROM zones WHERE id = ?', args: [req.params.id] }); res.status(204).send(); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/zones/:id/mow', async (req, res) => { try { const { notes, duration, weather, mowerId } = req.body; const today = new Date().toISOString(); // Get current zone data const zoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (zoneResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = zoneResult.rows[0]; // Update zone's last mowed date and calculate next mow date if using interval scheduling let sql, args; if (zone.scheduleType === 'interval' && zone.intervalDays) { const nextMowDate = calculateNextMowDate(today, zone.intervalDays); sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; args = [today, nextMowDate, req.params.id]; } else { // For specific date scheduling, just update last mowed date sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; args = [today, req.params.id]; } await db.execute({ sql, args }); // Add to mowing history await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)', args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'mowing'] }); const updatedResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); const updatedZone = { id: updatedResult.rows[0].id, name: updatedResult.rows[0].name, imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, nextMowDate: updatedResult.rows[0].nextMowDate, scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = await calculateZoneStatus(zone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); // Trimming endpoint (update zone schedule by the week) app.post('/api/zones/:id/trim', async (req, res) => { try { const { notes, duration, weather, mowerId } = req.body; const today = new Date().toISOString(); // Get current zone data to verify it exists const zoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (zoneResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = zoneResult.rows[0]; // Update the zone's next mowing date by adding one week // This applies the rule that trimming delays mowing by a week let updatedNextMowDate = null; if (zone.scheduleType === 'interval' && zone.intervalDays) { // For interval scheduling, calculate new next mow date const baseDate = zone.lastMowedDate ? new Date(zone.lastMowedDate) : new Date(); const originalNextMow = new Date(baseDate.getTime() + (zone.intervalDays * 24 * 60 * 60 * 1000)); updatedNextMowDate = new Date(originalNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString(); // Update the zone's nextMowDate await db.execute({ sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?', args: [updatedNextMowDate, req.params.id] }); } else if (zone.scheduleType === 'specific' && zone.nextMowDate) { // For specific date scheduling, push the date forward by one week const currentNextMow = new Date(zone.nextMowDate); updatedNextMowDate = new Date(currentNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString(); // Update the zone's nextMowDate await db.execute({ sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?', args: [updatedNextMowDate, req.params.id] }); } // Add to mowing history with trimming activity type // Note: We don't update the zone's lastMowedDate or nextMowDate for trimming await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)', args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'trimming'] }); res.json({ success: true, message: 'Trimming recorded successfully. Next mowing date has been delayed by one week.', updatedNextMowDate }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Bulk mowing endpoint app.post('/api/zones/bulk-mow', async (req, res) => { try { const { selectedZoneIds, notes, totalDuration, weather, mowerId, activityType = 'mowing' } = req.body; if (!selectedZoneIds || selectedZoneIds.length === 0) { return res.status(400).json({ error: 'No zones selected' }); } const today = new Date().toISOString(); const sessionId = generateSessionId(); // Get zone data for selected zones const zoneResults = await Promise.all( selectedZoneIds.map(id => db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [id] }) ) ); const zones = zoneResults.map(result => result.rows[0]).filter(Boolean); if (zones.length === 0) { return res.status(404).json({ error: 'No valid zones found' }); } // Calculate total area for proportional time distribution const totalArea = zones.reduce((sum, zone) => sum + (zone.area || 0), 0); // Process each zone for (const zone of zones) { // Calculate proportional duration let zoneDuration = null; if (totalDuration && totalArea > 0 && zone.area > 0) { const proportion = zone.area / totalArea; zoneDuration = Math.round(totalDuration * proportion); } // Only update zone schedule for mowing, not trimming if (activityType === 'mowing') { // Update zone's last mowed date and calculate next mow date if using interval scheduling let sql, args; if (zone.scheduleType === 'interval' && zone.intervalDays) { const nextMowDate = calculateNextMowDate(today, zone.intervalDays); sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; args = [today, nextMowDate, zone.id]; } else { // For specific date scheduling, just update last mowed date sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; args = [today, zone.id]; } await db.execute({ sql, args }); } else if (activityType === 'trimming') { // For trimming, delay the next mowing date by one week let updatedNextMowDate = null; if (zone.scheduleType === 'interval' && zone.intervalDays) { // For interval scheduling, calculate new next mow date const baseDate = zone.lastMowedDate ? new Date(zone.lastMowedDate) : new Date(); const originalNextMow = new Date(baseDate.getTime() + (zone.intervalDays * 24 * 60 * 60 * 1000)); updatedNextMowDate = new Date(originalNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString(); // Update the zone's nextMowDate await db.execute({ sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?', args: [updatedNextMowDate, zone.id] }); } else if (zone.scheduleType === 'specific' && zone.nextMowDate) { // For specific date scheduling, push the date forward by one week const currentNextMow = new Date(zone.nextMowDate); updatedNextMowDate = new Date(currentNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString(); // Update the zone's nextMowDate await db.execute({ sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?', args: [updatedNextMowDate, zone.id] }); } } // Add to mowing history with session ID await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId, activityType) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId, activityType] }); } res.json({ success: true, message: `Successfully recorded ${activityType} session for ${zones.length} zones${activityType === 'trimming' ? '. Next mowing dates have been delayed by one week.' : ''}`, sessionId, zonesUpdated: zones.length }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Serve React app in production if (process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); } // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Telegram test endpoint app.post('/api/telegram/test', async (req, res) => { try { const result = await testTelegramConnection(); res.json(result); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); // Manual trigger for mowing reminders app.post('/api/telegram/check-reminders', async (req, res) => { try { await checkMowingReminders(); res.json({ success: true, message: 'Mowing reminders checked and sent if needed' }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); // Manual trigger for weekly report app.post('/api/telegram/weekly-report', async (req, res) => { try { await sendWeeklyReport(); res.json({ success: true, message: 'Weekly report sent successfully' }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); // Season settings routes app.get('/api/season', async (req, res) => { try { const result = await db.execute('SELECT * FROM season_settings WHERE id = 1'); if (result.rows.length === 0) { // Return default settings if none exist res.json({ startMonth: 5, startDay: 1, endMonth: 9, endDay: 30, isActive: true }); } else { const settings = result.rows[0]; res.json({ id: settings.id, startMonth: settings.startMonth, startDay: settings.startDay, endMonth: settings.endMonth, endDay: settings.endDay, isActive: settings.isActive, createdAt: settings.createdAt, updatedAt: settings.updatedAt }); } } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/season', async (req, res) => { try { const { startMonth, startDay, endMonth, endDay, isActive } = req.body; // Check if settings exist const existingResult = await db.execute('SELECT * FROM season_settings WHERE id = 1'); if (existingResult.rows.length === 0) { // Create new settings const result = await db.execute({ sql: 'INSERT INTO season_settings (startMonth, startDay, endMonth, endDay, isActive) VALUES (?, ?, ?, ?, ?)', args: [startMonth, startDay, endMonth, endDay, isActive] }); const newSettings = await db.execute({ sql: 'SELECT * FROM season_settings WHERE id = ?', args: [result.lastInsertRowid] }); res.json({ id: newSettings.rows[0].id, startMonth: newSettings.rows[0].startMonth, startDay: newSettings.rows[0].startDay, endMonth: newSettings.rows[0].endMonth, endDay: newSettings.rows[0].endDay, isActive: newSettings.rows[0].isActive, createdAt: newSettings.rows[0].createdAt, updatedAt: newSettings.rows[0].updatedAt }); } else { // Update existing settings await db.execute({ sql: 'UPDATE season_settings SET startMonth = ?, startDay = ?, endMonth = ?, endDay = ?, isActive = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = 1', args: [startMonth, startDay, endMonth, endDay, isActive] }); const updatedSettings = await db.execute('SELECT * FROM season_settings WHERE id = 1'); res.json({ id: updatedSettings.rows[0].id, startMonth: updatedSettings.rows[0].startMonth, startDay: updatedSettings.rows[0].startDay, endMonth: updatedSettings.rows[0].endMonth, endDay: updatedSettings.rows[0].endDay, isActive: updatedSettings.rows[0].isActive, createdAt: updatedSettings.rows[0].createdAt, updatedAt: updatedSettings.rows[0].updatedAt }); } } catch (error) { res.status(500).json({ error: error.message }); } }); app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on http://0.0.0.0:${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); // Setup cron jobs for Telegram notifications if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) { console.log('Setting up Telegram notifications...'); // Daily mowing reminders at 8:00 AM cron.schedule('0 8 * * *', () => { console.log('Running daily mowing reminder check...'); checkMowingReminders(); }, { timezone: 'Europe/Moscow' // Adjust timezone as needed }); // Weekly report every Sunday at 9:00 AM cron.schedule('0 9 * * 0', () => { console.log('Sending weekly lawn care report...'); sendWeeklyReport(); }, { timezone: 'Europe/Moscow' // Adjust timezone as needed }); console.log('Telegram notifications scheduled successfully'); } else { console.log('Telegram notifications not configured (missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID)'); } });