From a27b35191de2ac212183d8b9e444c0d55e86b4ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:06:00 +0000 Subject: [PATCH] Fix permissions handling and surface write errors to the UI - Server attempts chmod on data dir at startup if not writable - All write routes now catch errors and return 500 with a readable message - Added /api/health endpoint to diagnose mount issues - Frontend propagates server errors to a visible banner Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ --- server.js | 64 +++++++++++++++++++++++++++++++++++------------ src/App.css | 25 ++++++++++++++++++ src/App.tsx | 41 ++++++++++++++++++++---------- src/usePrayers.ts | 17 ++++++++++--- 4 files changed, 115 insertions(+), 32 deletions(-) diff --git a/server.js b/server.js index dc4a6f0..153aecc 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,17 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DATA_FILE = path.join(DATA_DIR, 'prayers.json'); const PORT = process.env.PORT || 4000; +// Ensure data directory exists and is writable on startup +try { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.accessSync(DATA_DIR, fs.constants.W_OK); + console.log(`Data directory ready: ${DATA_DIR}`); +} catch (err) { + console.error(`WARNING: Data directory not writable: ${DATA_DIR} — ${err.message}`); + // Attempt chmod in case it's a simple permission gap + try { fs.chmodSync(DATA_DIR, 0o755); } catch {} +} + app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'build'))); @@ -26,32 +37,53 @@ function writePrayers(prayers) { fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2)); } +app.get('/api/health', (req, res) => { + let writable = false; + try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {} + res.json({ ok: writable, dataDir: DATA_DIR, dataFile: DATA_FILE }); +}); + app.get('/api/prayers', (req, res) => { res.json(readPrayers()); }); app.post('/api/prayers', (req, res) => { - const prayers = readPrayers(); - const prayer = req.body; - prayers.unshift(prayer); - writePrayers(prayers); - res.status(201).json(prayer); + try { + const prayers = readPrayers(); + const prayer = req.body; + prayers.unshift(prayer); + writePrayers(prayers); + res.status(201).json(prayer); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' }); + } }); app.put('/api/prayers/:id', (req, res) => { - const prayers = readPrayers(); - const idx = prayers.findIndex(p => p.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'Not found' }); - prayers[idx] = { ...prayers[idx], ...req.body }; - writePrayers(prayers); - res.json(prayers[idx]); + try { + const prayers = readPrayers(); + const idx = prayers.findIndex(p => p.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'Not found' }); + prayers[idx] = { ...prayers[idx], ...req.body }; + writePrayers(prayers); + res.json(prayers[idx]); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' }); + } }); app.delete('/api/prayers/:id', (req, res) => { - const prayers = readPrayers(); - const filtered = prayers.filter(p => p.id !== req.params.id); - writePrayers(filtered); - res.status(204).end(); + try { + const prayers = readPrayers(); + const filtered = prayers.filter(p => p.id !== req.params.id); + writePrayers(filtered); + res.status(204).end(); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not delete prayer. Check data directory permissions.' }); + } }); // Serve React for all other routes @@ -59,4 +91,4 @@ app.get('*splat', (req, res) => { res.sendFile(path.join(__dirname, 'build', 'index.html')); }); -app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); +app.listen(PORT, () => console.log(`Server running on port ${PORT}, data: ${DATA_FILE}`)); diff --git a/src/App.css b/src/App.css index 3fad4a4..d23d724 100644 --- a/src/App.css +++ b/src/App.css @@ -234,6 +234,31 @@ body { gap: 0.75rem; } +.error-banner { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #b91c1c; + border-radius: 8px; + padding: .75rem 1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .88rem; + margin-bottom: 1rem; +} + +@media (prefers-color-scheme: dark) { + .error-banner { + background: #2d1515; + border-color: #7f1d1d; + color: #fca5a5; + } +} + +:root[data-theme="dark"] .error-banner { + background: #2d1515; + border-color: #7f1d1d; + color: #fca5a5; +} + .loading { text-align: center; color: var(--text-muted); diff --git a/src/App.tsx b/src/App.tsx index 633bc0f..1373022 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ export default function App() { const { prayers, loading, + error: apiError, addPrayer, updateAnswer, markAnswered, @@ -23,6 +24,7 @@ export default function App() { const [newTags, setNewTags] = useState(''); const [expandedId, setExpandedId] = useState(null); const [editingAnswer, setEditingAnswer] = useState<{ id: string; text: string } | null>(null); + const [writeError, setWriteError] = useState(null); const filtered = prayers.filter(p => { if (filter === 'active') return p.status === 'active'; @@ -35,24 +37,31 @@ export default function App() { answered: prayers.filter(p => p.status === 'answered').length, }; - function handleAdd(e: React.FormEvent) { + async function handleAdd(e: React.FormEvent) { e.preventDefault(); if (!newRequest.trim()) return; - const tags = newTags - .split(',') - .map(t => t.trim()) - .filter(Boolean); - addPrayer(newRequest.trim(), tags); - setNewRequest(''); - setNewTags(''); + const tags = newTags.split(',').map(t => t.trim()).filter(Boolean); + try { + await addPrayer(newRequest.trim(), tags); + setNewRequest(''); + setNewTags(''); + setWriteError(null); + } catch (err: any) { + setWriteError(err.message); + } } - function handleSaveAnswer(id: string) { + async function handleSaveAnswer(id: string) { if (!editingAnswer) return; - updateAnswer(id, editingAnswer.text); - markAnswered(id); - setEditingAnswer(null); - setExpandedId(null); + try { + await updateAnswer(id, editingAnswer.text); + await markAnswered(id); + setEditingAnswer(null); + setExpandedId(null); + setWriteError(null); + } catch (err: any) { + setWriteError(err.message); + } } function formatDate(iso: string) { @@ -112,6 +121,12 @@ export default function App() { + {(writeError || apiError) && ( +
+ ⚠ {writeError || apiError} +
+ )} + {loading &&

Loading prayers…

}
    diff --git a/src/usePrayers.ts b/src/usePrayers.ts index 1617a4f..0e9155d 100644 --- a/src/usePrayers.ts +++ b/src/usePrayers.ts @@ -3,14 +3,24 @@ import { Prayer } from './types'; const API = '/api/prayers'; +async function checkResponse(r: Response) { + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new Error(body.error || `Server error ${r.status}`); + } + return r; +} + export function usePrayers() { const [prayers, setPrayers] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { fetch(API) .then(r => r.json()) .then(setPrayers) + .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, []); @@ -27,7 +37,7 @@ export function usePrayers() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(prayer), - }).then(r => r.json()); + }).then(checkResponse).then(r => r.json()); setPrayers(prev => [saved, ...prev]); }, []); @@ -36,7 +46,7 @@ export function usePrayers() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), - }).then(r => r.json()); + }).then(checkResponse).then(r => r.json()); setPrayers(prev => prev.map(p => p.id === id ? updated : p)); }, []); @@ -56,13 +66,14 @@ export function usePrayers() { patch(id, { request }), [patch]); const deletePrayer = useCallback(async (id: string) => { - await fetch(`${API}/${id}`, { method: 'DELETE' }); + await fetch(`${API}/${id}`, { method: 'DELETE' }).then(checkResponse); setPrayers(prev => prev.filter(p => p.id !== id)); }, []); return { prayers, loading, + error, addPrayer, updateAnswer, markAnswered,