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'; 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:lawn_scheduler.db' }); // Create zones table with area field await db.execute(` CREATE TABLE IF NOT EXISTS zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, imagePath TEXT, lastMowedDate TEXT, intervalDays INTEGER NOT NULL DEFAULT 7, area REAL DEFAULT 0, createdAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // 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 } // 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); await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', args: ['Front Yard', weekAgo.toISOString(), 7, 150.5] }); await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0] }); await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', args: ['Side Garden', today.toISOString(), 14, 75.25] }); } // Helper function to calculate zone status function calculateZoneStatus(zone) { const today = new Date(); const lastMowed = new Date(zone.lastMowedDate); const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24)); const daysUntilNext = zone.intervalDays - daysSinceLastMow; let status = 'ok'; if (daysUntilNext < 0) { status = 'overdue'; } else if (daysUntilNext <= 0) { status = 'due'; } else if (daysUntilNext <= 1) { status = 'due'; } return { ...zone, daysSinceLastMow, daysUntilNext, status, isOverdue: daysUntilNext < 0, isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1 }; } // 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, area: row.area || 0, createdAt: row.createdAt })); const zonesWithStatus = 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, 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 }); } }); app.post('/api/zones', upload.single('image'), async (req, res) => { try { const { name, intervalDays, area } = req.body; const imagePath = req.file ? `/uploads/${req.file.filename}` : null; const lastMowedDate = new Date().toISOString(); const result = await db.execute({ sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)', args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0] }); 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, 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, 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; await db.execute({ sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?', args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id] }); // 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, area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(updatedZone); 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 today = new Date().toISOString(); await db.execute({ sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?', args: [today, req.params.id] }); 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, area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(updatedZone); res.json(zoneWithStatus); } 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() }); }); 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'}`); });