344 lines
9.9 KiB
JavaScript
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'}`);
|
|
}); |