import express from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 80; const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data'); // Storage file paths const TRIPS_FILE = path.join(DATA_DIR, 'trips.json'); const MEMORY_FILE = path.join(DATA_DIR, 'memory.json'); const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json'); // Ensure storage directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Helpers for safe file I/O function readJson(file, defaultVal) { try { if (fs.existsSync(file)) { const data = fs.readFileSync(file, 'utf8'); return JSON.parse(data); } } catch (err) { console.error(`Error reading ${file}:`, err); } return defaultVal; } function writeJson(file, data) { try { fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8'); } catch (err) { console.error(`Error writing ${file}:`, err); } } // Seed default memory if empty if (!fs.existsSync(MEMORY_FILE)) { writeJson(MEMORY_FILE, { exclusions: [ { itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' }, { itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' }, { itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' }, ], inclusions: [ { itemName: 'Packable laundry detergent sheets', category: 'clothing', defaultBag: 'carry_on', tripType: 'international_urban', count: 2, lastDate: '2026-06-20', reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts', }, { itemName: 'Compact 3-prong outlet splitter', category: 'tech', defaultBag: 'personal_item', tripType: 'business', count: 2, lastDate: '2026-09-02', reason: 'Airport and hotel outlets are always limited or loose', }, ], totalTripsDebriefed: 2, }); } // Middleware app.use(cors()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Healthcheck endpoint for Docker & Traefik app.get('/api/health', (_req, res) => { res.json({ status: 'ok', app: 'loadout', timestamp: new Date().toISOString(), uptimeSeconds: Math.floor(process.uptime()), }); }); // GET /api/trips app.get('/api/trips', (_req, res) => { const trips = readJson(TRIPS_FILE, []); res.json(trips); }); // POST /api/trips app.post('/api/trips', (req, res) => { const trip = req.body; if (!trip || !trip.id) { return res.status(400).json({ error: 'Invalid trip data' }); } const trips = readJson(TRIPS_FILE, []); const index = trips.findIndex((t) => t.id === trip.id); if (index >= 0) { trips[index] = trip; } else { trips.unshift(trip); } writeJson(TRIPS_FILE, trips); res.json({ success: true, trip }); }); // DELETE /api/trips/:id app.delete('/api/trips/:id', (req, res) => { const { id } = req.params; let trips = readJson(TRIPS_FILE, []); trips = trips.filter((t) => t.id !== id); writeJson(TRIPS_FILE, trips); res.json({ success: true }); }); // GET /api/memory app.get('/api/memory', (_req, res) => { const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 }); res.json(memory); }); // POST /api/memory/debrief app.post('/api/memory/debrief', (req, res) => { const { tripId, debrief } = req.body; if (!tripId || !debrief) { return res.status(400).json({ error: 'Missing tripId or debrief data' }); } const trips = readJson(TRIPS_FILE, []); const trip = trips.find((t) => t.id === tripId); if (!trip) { return res.status(404).json({ error: 'Trip not found' }); } trip.debrief = debrief; writeJson(TRIPS_FILE, trips); // Update memory const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 }); memory.totalTripsDebriefed = (memory.totalTripsDebriefed || 0) + 1; // Record deadweight exclusions if (Array.isArray(debrief.excludedItemNames)) { for (const itemName of debrief.excludedItemNames) { const existing = memory.exclusions.find( (e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase() ); if (existing) { existing.count += 1; existing.lastDate = new Date().toISOString(); } else { memory.exclusions.push({ itemName, tripType: trip.tripType, count: 1, lastDate: new Date().toISOString(), }); } } } // Record wishlist inclusions if (Array.isArray(debrief.includedItemNames)) { for (const itemName of debrief.includedItemNames) { const existing = memory.inclusions.find( (i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase() ); if (existing) { existing.count += 1; existing.lastDate = new Date().toISOString(); } else { memory.inclusions.push({ itemName, category: 'gear', defaultBag: 'carry_on', tripType: trip.tripType, count: 1, lastDate: new Date().toISOString(), reason: `Wished for after trip: ${trip.title}`, }); } } } writeJson(MEMORY_FILE, memory); res.json({ success: true, memory }); }); // GET /api/settings app.get('/api/settings', (_req, res) => { const settings = readJson(SETTINGS_FILE, { soundEnabled: true, compactView: false, aiEnabled: false, aiProvider: 'ollama', ollamaUrl: 'http://localhost:11434', ollamaModel: 'llama3.2', apiKey: '', customBags: [], customArchetypes: [], }); res.json(settings); }); // POST /api/settings app.post('/api/settings', (req, res) => { const settings = req.body; writeJson(SETTINGS_FILE, settings); res.json({ success: true, settings }); }); // POST /api/ai/suggest app.post('/api/ai/suggest', async (req, res) => { const { prompt, tripDetails } = req.body; const settings = readJson(SETTINGS_FILE, {}); if (!settings.aiEnabled) { return res.status(400).json({ error: 'AI features are disabled in settings' }); } try { if (settings.aiProvider === 'ollama') { const ollamaUrl = (settings.ollamaUrl || 'http://localhost:11434').replace(/\/$/, ''); const response = await fetch(`${ollamaUrl}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.ollamaModel || 'llama3.2', prompt: `You are a travel packing specialist. Based on this trip: ${JSON.stringify(tripDetails)}, suggest 3 to 5 hyper-specific, high-value packing items or travel wisdom tips. Respond in clear bullet points with concise reasoning.`, stream: false, }), }); if (!response.ok) { throw new Error(`Ollama returned status ${response.status}`); } const data = await response.json(); return res.json({ suggestion: data.response }); } return res.status(400).json({ error: 'Configured AI provider not supported directly yet' }); } catch (err) { console.error('AI suggest error:', err); return res.status(500).json({ error: 'Failed to communicate with AI provider: ' + err.message }); } }); // Serve frontend static build in production const DIST_DIR = path.resolve(__dirname, '../dist'); if (fs.existsSync(DIST_DIR)) { app.use(express.static(DIST_DIR)); app.use((req, res, next) => { if (req.method === 'GET' && !req.path.startsWith('/api')) { return res.sendFile(path.join(DIST_DIR, 'index.html')); } next(); }); } app.listen(PORT, '0.0.0.0', () => { console.log(`[LOADOUT] Server listening on port ${PORT}`); console.log(`[LOADOUT] Data directory: ${DATA_DIR}`); });