lawnmowing/server/index.js
2025-06-29 16:26:42 +03:00

344 lines
9.9 KiB
JavaScript

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