From be1ef2920e0caf63e615a907df2c880052336b1a Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 00:24:54 -0400 Subject: [PATCH 1/7] fix: correct multi-image mosaic positioning and unify POI media handling Fixes critical issues introduced in PR #182 where the multi-image mosaic was incorrectly rendering inside the Info tab instead of at the top of the sidebar, and media handling was not unified across POI types. **Fixed Issues:** - Mosaic now renders at sidebar top (between header and tabs) for all POIs - Removed duplicate media rendering from ReadOnlyView component - Unified media handling: destinations, linear features, and virtual POIs now use identical Mosaic/Lightbox code path - Added primary image indicators (grey star in mosaic, gold badge in lightbox) - Fixed lightbox navigation when setting primary image (stays on same image) - Added event emission for map marker thumbnail updates - Fixed pending badge async updates with event-driven system - Fixed moderation count badge updates on upload - Standardized button positioning (delete: bottom-left, add/set-primary: bottom-right) - Increased caption length limit from 200 to 2000 characters (migration 017) - Fixed moderation queue to show poi_media table items - Fixed event listener thrashing with stable dependencies **Database:** - Migration 017: Increase poi_media.caption length to 2000 chars **Technical Changes:** - Removed showImage parameter from ReadOnlyView/EditView - Media state management now at Sidebar component level only - Linear features now use same Mosaic component as destinations - Event system: poi-media-updated, poi-updated, moderation-count-changed - Inline refresh logic in event handlers to prevent stale closures **Related:** - Addresses issues discovered during PR #182 review - Creates issue #184 for future POI type unification refactor Co-Authored-By: Claude Sonnet 4.5 --- backend/config/passport.js | 29 +- .../017_increase_caption_length.sql | 11 + backend/routes/admin.js | 7 +- backend/routes/auth.js | 41 +- backend/server.js | 221 +++++++++-- backend/services/moderationService.js | 39 +- frontend/src/App.css | 85 +++- frontend/src/App.jsx | 17 +- frontend/src/components/ImageUploader.jsx | 39 +- frontend/src/components/Lightbox.css | 199 +++++++++- frontend/src/components/Lightbox.jsx | 175 ++++++++- frontend/src/components/MediaUploadModal.css | 4 +- frontend/src/components/MediaUploadModal.jsx | 16 +- frontend/src/components/ModerationInbox.jsx | 202 +++++++++- frontend/src/components/Mosaic.css | 33 +- frontend/src/components/Mosaic.jsx | 16 +- frontend/src/components/Sidebar.jsx | 369 ++++++++---------- frontend/src/components/SyncSettings.jsx | 8 + frontend/src/main.jsx | 8 +- 19 files changed, 1211 insertions(+), 308 deletions(-) create mode 100644 backend/migrations/017_increase_caption_length.sql diff --git a/backend/config/passport.js b/backend/config/passport.js index 45a01461..d2e9f783 100644 --- a/backend/config/passport.js +++ b/backend/config/passport.js @@ -81,17 +81,35 @@ export function configurePassport(pool) { return insertResult.rows[0]; } - // Google OAuth Strategy - request drive.file scope for all users - // (non-admins won't use it, but it simplifies the flow) - // Note: accessType and prompt are set in auth.js route, not here + // Google OAuth Strategy (dual-strategy approach for conditional Drive access) + // Standard strategy - all users get basic profile + email scopes only + // Admin users are auto-redirected to upgrade flow if they lack Drive credentials if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { - passport.use(new GoogleStrategy({ + // Standard strategy - basic scopes for all users (no Drive access) + passport.use('google', new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: process.env.GOOGLE_CALLBACK_URL || '/auth/google/callback', + scope: ['profile', 'email'] + }, async (accessToken, refreshToken, profile, done) => { + try { + // Don't store credentials for standard login (admin will get them via upgrade flow) + const user = await findOrCreateUser('google', profile, null); + done(null, user); + } catch (error) { + done(error); + } + })); + + // Upgrade strategy - Drive scope for admin only (incremental authorization) + passport.use('google-upgrade', new GoogleStrategy({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: '/auth/google/upgrade/callback', scope: ['profile', 'email', 'https://www.googleapis.com/auth/drive.file'] }, async (accessToken, refreshToken, profile, done) => { try { + // Store Drive credentials for admin const credentials = { access_token: accessToken, refresh_token: refreshToken @@ -102,7 +120,8 @@ export function configurePassport(pool) { done(error); } })); - console.log('Google OAuth strategy configured'); + + console.log('Google OAuth strategies configured (standard + upgrade)'); } else { console.log('Google OAuth not configured (missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET)'); } diff --git a/backend/migrations/017_increase_caption_length.sql b/backend/migrations/017_increase_caption_length.sql new file mode 100644 index 00000000..946b3191 --- /dev/null +++ b/backend/migrations/017_increase_caption_length.sql @@ -0,0 +1,11 @@ +-- Migration 017: Increase caption length limit +-- Created: 2026-04-04 +-- Description: Increase poi_media caption limit from 200 to 2000 characters +-- to allow for more descriptive captions + +-- Drop the existing 200-character constraint +ALTER TABLE poi_media DROP CONSTRAINT IF EXISTS poi_media_caption_length_check; + +-- Add new constraint with 2000-character limit +ALTER TABLE poi_media ADD CONSTRAINT poi_media_caption_length_check + CHECK (caption IS NULL OR length(caption) <= 2000); diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 4888073e..534eb966 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -3922,14 +3922,17 @@ export function createAdminRouter(pool, invalidateMosaicCache) { router.post('/moderation/save', isAdmin, async (req, res) => { try { const { type, id, edits } = req.body; + console.log('[Moderation Save] Request:', { type, id, edits }); if (!type || !id || !edits) { return res.status(400).json({ error: 'type, id, and edits are required' }); } await editAndPublish(pool, type, id, edits, req.user.id, { publish: false }); + console.log('[Moderation Save] Success'); res.json({ success: true }); } catch (error) { - console.error('Error saving edits:', error); - res.status(500).json({ error: 'Failed to save edits' }); + console.error('[Moderation Save] Error:', error.message); + console.error('[Moderation Save] Stack:', error.stack); + res.status(500).json({ error: 'Failed to save edits', details: error.message }); } }); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 361e417c..3380e487 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -4,23 +4,46 @@ import passport from 'passport'; const router = express.Router(); const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8080'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'scott.mccarty@gmail.com'; -// Google OAuth - single flow for all users -// Admins get drive.file scope and credentials stored in database -// accessType: 'offline' requests a refresh token -// prompt: 'consent' forces consent screen to ensure we get refresh token +// Google OAuth - dual-strategy approach for conditional Drive access +// Standard route: all users authenticate with basic scopes (profile + email) +// Upgrade route: admin-only Drive scope via incremental authorization +// Auto-detection: admin users without Drive credentials are redirected to upgrade flow // Fix: Only register routes if strategy is configured (prevents "Unknown strategy" error) if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { - router.get('/google', passport.authenticate('google', { + // Standard Google OAuth (all users - basic scopes only) + router.get('/google', passport.authenticate('google')); + + router.get('/google/callback', + passport.authenticate('google', { failureRedirect: `${FRONTEND_URL}?auth=failed` }), + async (req, res) => { + // Auto-detect admin without Drive credentials + const isAdmin = req.user.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase(); + const hasCredentials = req.user.oauth_credentials && + JSON.parse(req.user.oauth_credentials).access_token; + + if (isAdmin && !hasCredentials) { + // Redirect admin to upgrade flow for Drive access + return res.redirect('/auth/google/upgrade'); + } + + // Standard success redirect + res.redirect(`${FRONTEND_URL}?auth=success`); + } + ); + + // Drive scope upgrade (admin only - incremental authorization) + router.get('/google/upgrade', passport.authenticate('google-upgrade', { accessType: 'offline', prompt: 'consent' })); - router.get('/google/callback', - passport.authenticate('google', { failureRedirect: `${FRONTEND_URL}?auth=failed` }), + router.get('/google/upgrade/callback', + passport.authenticate('google-upgrade', { failureRedirect: `${FRONTEND_URL}?auth=failed` }), (req, res) => { - // Always redirect to View tab (default) after login - res.redirect(`${FRONTEND_URL}?auth=success`); + // Redirect to Sync Settings after Drive access granted + res.redirect(`${FRONTEND_URL}/admin?auth=success&tab=sync`); } ); } else { diff --git a/backend/server.js b/backend/server.js index 045badaf..98f8fda9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1008,14 +1008,17 @@ app.get('/api/pois/:id/thumbnail', async (req, res) => { app.get('/api/pois/:id/media', async (req, res) => { try { const { id } = req.params; + const userId = req.user?.id || null; // Optional authentication - // Check cache first (5min TTL reduces DB load) - const cached = getMosaicFromCache(id); - if (cached) { - return res.json(cached); + // Skip cache if authenticated (user might have pending uploads) + if (!userId) { + const cached = getMosaicFromCache(id); + if (cached) { + return res.json(cached); + } } - // Query approved media from poi_media table + // Query media: published for everyone, pending only for uploader const result = await pool.query(` SELECT id, @@ -1026,15 +1029,20 @@ app.get('/api/pois/:id/media', async (req, res) => { sort_order, likes_count, caption, - created_at + created_at, + moderation_status, + submitted_by FROM poi_media WHERE poi_id = $1 - AND moderation_status IN ('published', 'auto_approved') + AND ( + moderation_status IN ('published', 'auto_approved') + OR (moderation_status = 'pending' AND submitted_by = $2) + ) ORDER BY CASE WHEN role = 'primary' THEN 0 ELSE 1 END, likes_count DESC, created_at DESC - `, [id]); + `, [id, userId]); const allMedia = []; @@ -1046,7 +1054,9 @@ app.get('/api/pois/:id/media', async (req, res) => { role: media.role, likes_count: media.likes_count, caption: media.caption, - created_at: media.created_at + created_at: media.created_at, + moderation_status: media.moderation_status, + uploaded_by_user: userId && media.submitted_by === userId }; if (media.media_type === 'youtube') { @@ -1075,8 +1085,10 @@ app.get('/api/pois/:id/media', async (req, res) => { total_count: allMedia.length }; - // Cache the result for 5 minutes - setMosaicCache(id, response); + // Only cache for anonymous users (authenticated users see their pending uploads) + if (!userId) { + setMosaicCache(id, response); + } res.json(response); } catch (error) { @@ -1147,18 +1159,26 @@ app.post('/api/pois/:id/media', isAuthenticated, upload.single('file'), async (r .substring(0, 255); // Limit length // Upload to image server - const uploadFn = media_type === 'image' - ? imageServerClient.uploadImage - : imageServerClient.uploadVideo; - - const uploadResult = await uploadFn.call( - imageServerClient, - req.file.buffer, - parseInt(id), - 'gallery', - sanitizedFilename, - req.file.mimetype - ); + let uploadResult; + if (media_type === 'image') { + // uploadImage(imageBuffer, poiId, role, filename, mimeType, options) + uploadResult = await imageServerClient.uploadImage( + req.file.buffer, + parseInt(id), + 'gallery', + sanitizedFilename, + req.file.mimetype + ); + } else { + // uploadVideo(videoBuffer, poiId, filename, mimeType, role) + uploadResult = await imageServerClient.uploadVideo( + req.file.buffer, + parseInt(id), + sanitizedFilename, + req.file.mimetype, + 'gallery' + ); + } if (!uploadResult.success) { return res.status(500).json({ error: 'Failed to upload: ' + uploadResult.error }); @@ -1167,11 +1187,11 @@ app.post('/api/pois/:id/media', isAuthenticated, upload.single('file'), async (r assetId = uploadResult.assetId; } - // Determine moderation status - const isMediaAdminUser = user.role === 'media_admin' || user.role === 'admin'; - const moderationStatus = isMediaAdminUser ? 'published' : 'pending'; - const moderatedAt = isMediaAdminUser ? new Date() : null; - const moderatedBy = isMediaAdminUser ? user.id : null; + // All uploads via this interface go to moderation queue + // (Admin panel uploads can still bypass queue) + const moderationStatus = 'pending'; + const moderatedAt = null; + const moderatedBy = null; // Set during approval, not upload // Create poi_media record const insertResult = await pool.query(` @@ -1203,9 +1223,8 @@ app.post('/api/pois/:id/media', isAuthenticated, upload.single('file'), async (r const mediaId = insertResult.rows[0].id; - const message = isMediaAdminUser - ? 'Media uploaded and published' - : 'Media submitted for review'; + // All uploads go to moderation queue + const message = 'Media submitted for review'; // Invalidate mosaic cache for this POI (new media uploaded) invalidateMosaicCache(id); @@ -1222,6 +1241,146 @@ app.post('/api/pois/:id/media', isAuthenticated, upload.single('file'), async (r } }); +/** + * DELETE /api/pois/:poiId/media/:mediaId + * Delete media (only allowed for uploader or admin) + */ +app.delete('/api/pois/:poiId/media/:mediaId', isAuthenticated, async (req, res) => { + try { + const { poiId, mediaId } = req.params; + const user = req.user; + + // Check if media exists and get ownership info + const mediaResult = await pool.query( + 'SELECT submitted_by, image_server_asset_id FROM poi_media WHERE id = $1 AND poi_id = $2', + [mediaId, poiId] + ); + + if (mediaResult.rows.length === 0) { + return res.status(404).json({ error: 'Media not found' }); + } + + const media = mediaResult.rows[0]; + + // Check permission: user must be the uploader or an admin + const isOwner = media.submitted_by === user.id; + const isAdmin = user.role === 'admin' || user.role === 'media_admin'; + + if (!isOwner && !isAdmin) { + return res.status(403).json({ error: 'You can only delete your own media' }); + } + + // Delete from database + await pool.query('DELETE FROM poi_media WHERE id = $1', [mediaId]); + + // Delete from image server (if it's an image/video, not YouTube) + if (media.image_server_asset_id) { + try { + await imageServerClient.deleteAsset(media.image_server_asset_id); + } catch (err) { + console.error('Failed to delete asset from image server:', err); + // Continue anyway - DB record is deleted + } + } + + // Update POI's has_primary_image flag based on remaining media + const remainingPrimary = await pool.query( + `SELECT id FROM poi_media + WHERE poi_id = $1 + AND role = 'primary' + AND moderation_status IN ('published', 'auto_approved') + LIMIT 1`, + [poiId] + ); + + await pool.query( + 'UPDATE pois SET has_primary_image = $1 WHERE id = $2', + [remainingPrimary.rows.length > 0, poiId] + ); + + // Invalidate mosaic cache + invalidateMosaicCache(poiId); + + res.json({ success: true, message: 'Media deleted' }); + } catch (error) { + console.error('Error deleting media:', error); + res.status(500).json({ error: 'Failed to delete media' }); + } +}); + +/** + * PATCH /api/pois/:poiId/media/:mediaId/set-primary + * Set media as primary (admins only) + */ +app.patch('/api/pois/:poiId/media/:mediaId/set-primary', isAuthenticated, async (req, res) => { + try { + const { poiId, mediaId } = req.params; + const user = req.user; + + // Check admin permission + const isAdmin = user.role === 'admin' || user.role === 'media_admin'; + if (!isAdmin) { + return res.status(403).json({ error: 'Only admins can set primary images' }); + } + + // Check if media exists and is published + const mediaResult = await pool.query( + 'SELECT id, role, moderation_status FROM poi_media WHERE id = $1 AND poi_id = $2', + [mediaId, poiId] + ); + + if (mediaResult.rows.length === 0) { + return res.status(404).json({ error: 'Media not found' }); + } + + const media = mediaResult.rows[0]; + + // Only published/auto-approved media can be primary + if (!['published', 'auto_approved'].includes(media.moderation_status)) { + return res.status(400).json({ error: 'Only approved media can be set as primary' }); + } + + // Already primary + if (media.role === 'primary') { + return res.json({ success: true, message: 'Already primary' }); + } + + // Transaction: demote old primary to gallery, promote new to primary + await pool.query('BEGIN'); + + // Demote old primary to gallery + await pool.query( + `UPDATE poi_media SET role = 'gallery' + WHERE poi_id = $1 AND role = 'primary' + AND moderation_status IN ('published', 'auto_approved')`, + [poiId] + ); + + // Promote new media to primary + await pool.query( + `UPDATE poi_media SET role = 'primary' WHERE id = $1`, + [mediaId] + ); + + // Update POI's has_primary_image flag + await pool.query( + 'UPDATE pois SET has_primary_image = true WHERE id = $1', + [poiId] + ); + + await pool.query('COMMIT'); + + // Invalidate mosaic cache + invalidateMosaicCache(poiId); + + res.json({ success: true, message: 'Primary image updated' }); + } catch (error) { + await pool.query('ROLLBACK'); + console.error('Error setting primary media:', error); + res.status(500).json({ error: 'Failed to set primary media' }); + } +}); + /** * GET /api/assets/:assetId/thumbnail * Proxy thumbnail from image server diff --git a/backend/services/moderationService.js b/backend/services/moderationService.js index b016fdf3..ed239f30 100644 --- a/backend/services/moderationService.js +++ b/backend/services/moderationService.js @@ -489,6 +489,8 @@ export async function editAndPublish(pool, contentType, contentId, edits, adminU : contentType === 'event' ? EDITABLE_EVENT : EDITABLE_PHOTO; const table = TABLE_MAP[contentType]; + console.log('[editAndPublish]', { contentType, contentId, edits, table, allowedFields }); + const setClauses = []; const values = [contentId]; let idx = 2; @@ -503,8 +505,8 @@ export async function editAndPublish(pool, contentType, contentId, edits, adminU } } - // When admin sets publication_date, mark confidence as 'exact' - if (edits.publication_date) { + // When admin sets publication_date, mark confidence as 'exact' (only for news/events, not photos) + if (edits.publication_date && contentType !== 'photo') { setClauses.push(`date_confidence = 'exact'`); } @@ -515,6 +517,7 @@ export async function editAndPublish(pool, contentType, contentId, edits, adminU } if (setClauses.length === 0) return; + console.log('[editAndPublish] SQL:', `UPDATE ${table} SET ${setClauses.join(', ')} WHERE id = $1`, values); await pool.query(`UPDATE ${table} SET ${setClauses.join(', ')} WHERE id = $1`, values); } @@ -931,7 +934,8 @@ export async function getQueue(pool, { page = 1, limit = 20, contentType = null, n.submitted_by, n.moderated_by, n.moderated_at, n.created_at, n.source_url, n.content_source, n.publication_date, n.date_confidence, NULL::TIMESTAMPTZ AS start_date, NULL::TIMESTAMPTZ AS end_date, - COUNT(u.id)::int AS additional_url_count + COUNT(u.id)::int AS additional_url_count, + NULL::VARCHAR AS media_type, NULL::VARCHAR AS image_server_asset_id, NULL::VARCHAR AS role FROM poi_news n LEFT JOIN poi_news_urls u ON u.news_id = n.id WHERE n.moderation_status = ANY($1) @@ -942,19 +946,26 @@ export async function getQueue(pool, { page = 1, limit = 20, contentType = null, e.submitted_by, e.moderated_by, e.moderated_at, e.created_at, e.source_url, e.content_source, e.publication_date, e.date_confidence, e.start_date, e.end_date, - COUNT(u.id)::int AS additional_url_count + COUNT(u.id)::int AS additional_url_count, + NULL::VARCHAR AS media_type, NULL::VARCHAR AS image_server_asset_id, NULL::VARCHAR AS role FROM poi_events e LEFT JOIN poi_event_urls u ON u.event_id = e.id WHERE e.moderation_status = ANY($1) GROUP BY e.id UNION ALL - SELECT id, 'photo' AS content_type, poi_id, original_filename AS title, caption AS description, + SELECT id, 'photo' AS content_type, poi_id, + CASE + WHEN media_type = 'youtube' THEN youtube_url + ELSE CONCAT(media_type, ' #', id) + END AS title, + caption AS description, moderation_status, confidence_score, ai_reasoning, NULL AS ai_issues, - submitted_by, moderated_by, moderated_at, created_at, NULL AS source_url, + submitted_by, moderated_by, moderated_at, created_at, youtube_url AS source_url, NULL AS content_source, NULL::DATE AS publication_date, NULL::VARCHAR AS date_confidence, NULL::TIMESTAMPTZ AS start_date, NULL::TIMESTAMPTZ AS end_date, - 0 AS additional_url_count - FROM photo_submissions WHERE moderation_status = ANY($1)`; + 0 AS additional_url_count, + media_type, image_server_asset_id, role + FROM poi_media WHERE moderation_status = ANY($1)`; const filters = []; const params = [statusList]; @@ -992,7 +1003,15 @@ export async function getQueue(pool, { page = 1, limit = 20, contentType = null, } export async function getPendingCount(pool) { - const countRow = await pool.query(`SELECT COUNT(*) FROM moderation_queue`); + const countRow = await pool.query(` + SELECT COUNT(*) FROM ( + SELECT id FROM poi_news WHERE moderation_status = 'pending' + UNION ALL + SELECT id FROM poi_events WHERE moderation_status = 'pending' + UNION ALL + SELECT id FROM poi_media WHERE moderation_status = 'pending' + ) AS pending_items + `); return parseInt(countRow.rows[0].count); } @@ -1004,7 +1023,7 @@ export async function getItemDetail(pool, contentType, contentId) { event: `SELECT e.*, p.name as poi_name, COALESCE(json_agg(json_build_object('id', u.id, 'url', u.url, 'source_name', u.source_name)) FILTER (WHERE u.id IS NOT NULL), '[]'::json) AS additional_urls FROM poi_events e LEFT JOIN pois p ON e.poi_id = p.id LEFT JOIN poi_event_urls u ON u.event_id = e.id WHERE e.id = $1 GROUP BY e.id, p.name`, - photo: `SELECT ps.*, p.name as poi_name FROM photo_submissions ps LEFT JOIN pois p ON ps.poi_id = p.id WHERE ps.id = $1` + photo: `SELECT pm.*, p.name as poi_name FROM poi_media pm LEFT JOIN pois p ON pm.poi_id = p.id WHERE pm.id = $1` }; const sql = queryMap[contentType]; diff --git a/frontend/src/App.css b/frontend/src/App.css index bb31e2c0..43e7223f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1057,6 +1057,13 @@ body { } /* Sidebar Image */ +/* Multi-media section (mosaic or single image) */ +.sidebar-media-section { + width: 100%; + flex-shrink: 0; + background: #f5f5f5; +} + .sidebar-image { position: relative; width: 100%; @@ -1083,6 +1090,42 @@ body { padding: 0.25rem; } +/* No media state - empty placeholder with upload button */ +.sidebar-no-media { + position: relative; + width: 100%; + height: 200px; + min-height: 200px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; +} + +.btn-add-first-media { + background: #28a745; + color: white; + border: none; + padding: 12px 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s, transform 0.1s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.btn-add-first-media:hover { + background: #218838; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.btn-add-first-media:active { + transform: translateY(0); +} + .image-placeholder { width: 100%; height: 100%; @@ -6479,6 +6522,33 @@ body { font-size: 0.9rem; } +.drive-access-prompt { + background: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.drive-access-prompt p { + margin: 0; + font-weight: 500; +} + +.drive-access-prompt .sync-btn { + flex-shrink: 0; + white-space: nowrap; + text-decoration: none; + display: inline-block; + padding: 0.5rem 1rem; + text-align: center; +} + .sync-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); @@ -10228,7 +10298,8 @@ body { } .image-change-btn, -.image-delete-btn { +.image-delete-btn, +.image-add-btn { padding: 0.35rem 0.75rem; border: none; border-radius: 4px; @@ -10255,8 +10326,18 @@ body { background: #c00; } +.image-add-btn { + background: rgba(0,123,255,0.9); + color: #fff; +} + +.image-add-btn:hover { + background: #007bff; +} + .image-change-btn:disabled, -.image-delete-btn:disabled { +.image-delete-btn:disabled, +.image-add-btn:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 128daf63..14482d9a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -297,13 +297,16 @@ function AppContent() { // Fetch moderation pending count for admin badge const refreshModerationCount = useCallback(async () => { + console.log('[App] Refreshing moderation count...'); try { const response = await fetch('/api/admin/moderation/queue/count', { credentials: 'include' }); if (response.ok) { const data = await response.json(); + console.log('[App] New moderation count:', data.count); setModerationCount(data.count); } } catch (err) { + console.error('[App] Failed to refresh moderation count:', err); // Silently ignore — badge just won't show } }, []); @@ -312,7 +315,18 @@ function AppContent() { if (!isAdmin) return; refreshModerationCount(); const interval = setInterval(refreshModerationCount, 60000); - return () => clearInterval(interval); + + // Listen for media upload events to refresh count + const handleCountChanged = () => { + console.log('[App] Received moderation-count-changed event'); + refreshModerationCount(); + }; + window.addEventListener('moderation-count-changed', handleCountChanged); + + return () => { + clearInterval(interval); + window.removeEventListener('moderation-count-changed', handleCountChanged); + }; }, [isAdmin, refreshModerationCount]); // Preview coordinates for real-time editing sync between Map and Sidebar @@ -1879,6 +1893,7 @@ function AppContent() { navigate('/mtb-trail-status'); }} isAdmin={isAdmin} + user={user} editMode={editMode} onDestinationUpdate={handleDestinationUpdate} onDestinationDelete={handleDestinationDelete} diff --git a/frontend/src/components/ImageUploader.jsx b/frontend/src/components/ImageUploader.jsx index 6364aa51..4309ac56 100644 --- a/frontend/src/components/ImageUploader.jsx +++ b/frontend/src/components/ImageUploader.jsx @@ -1,4 +1,5 @@ import React, { useState, useRef } from 'react'; +import MediaUploadModal from './MediaUploadModal'; function ImageUploader({ destinationId, @@ -7,10 +8,14 @@ function ImageUploader({ onPendingImageChange, disabled, isVirtualPoi, - updatedAt + updatedAt, + user, + poiId, + onMediaUpdate }) { const [error, setError] = useState(null); const [dragActive, setDragActive] = useState(false); + const [uploadModalOpen, setUploadModalOpen] = useState(false); const fileInputRef = useRef(null); // Determine what to show: @@ -138,6 +143,16 @@ function ImageUploader({ fileInputRef.current?.click(); }; + const handleUploadSuccess = () => { + setUploadModalOpen(false); + if (onMediaUpdate) { + onMediaUpdate(); + } + }; + + // Check if user is media_admin or admin + const isMediaAdmin = user && (user.role === 'media_admin' || user.role === 'admin'); + return (
@@ -177,6 +192,19 @@ function ImageUploader({ > Delete + {isMediaAdmin && poiId && ( + + )}
) : ( @@ -211,6 +239,15 @@ function ImageUploader({ style={{ display: 'none' }} disabled={disabled} /> + + {/* Upload Modal for additional images (admin only) */} + {uploadModalOpen && poiId && ( + setUploadModalOpen(false)} + onSuccess={handleUploadSuccess} + /> + )} ); } diff --git a/frontend/src/components/Lightbox.css b/frontend/src/components/Lightbox.css index 2f1003a9..72fbc7eb 100644 --- a/frontend/src/components/Lightbox.css +++ b/frontend/src/components/Lightbox.css @@ -6,7 +6,7 @@ right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.95); - z-index: 9999; + z-index: 10001; display: flex; align-items: center; justify-content: center; @@ -120,9 +120,9 @@ /* YouTube Container */ .lightbox-youtube-container { position: relative; - width: 100%; - max-width: 1280px; - aspect-ratio: 16 / 9; + width: min(90vw, 1280px); + height: min(calc(90vw * 9 / 16), calc((100vh - 280px) * 0.9)); + max-height: 720px; } .lightbox-youtube { @@ -132,6 +132,7 @@ width: 100%; height: 100%; border-radius: 4px; + border: none; } /* Caption */ @@ -157,6 +158,41 @@ z-index: 10001; } +/* Pending Review Badge */ +.lightbox-pending-badge { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 165, 0, 0.95); + color: white; + padding: 10px 20px; + border-radius: 20px; + font-size: 15px; + font-weight: 600; + z-index: 10001; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Primary Image Badge */ +.lightbox-primary-badge { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 193, 7, 0.95); + color: #333; + padding: 12px 24px; + border-radius: 25px; + font-size: 18px; + font-weight: 700; + z-index: 10001; + text-transform: uppercase; + letter-spacing: 1px; + box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4); +} + /* Thumbnail Strip */ .lightbox-thumbnails { position: absolute; @@ -220,7 +256,8 @@ /* Thumbnail Media Indicators */ .thumbnail-video-indicator, -.thumbnail-youtube-indicator { +.thumbnail-youtube-indicator, +.thumbnail-pending-indicator { position: absolute; bottom: 4px; right: 4px; @@ -232,6 +269,124 @@ font-weight: bold; } +.thumbnail-pending-indicator { + background: rgba(255, 165, 0, 0.9); + bottom: 4px; + left: 4px; + right: auto; +} + +/* Set as Primary Button */ +.lightbox-set-primary { + position: absolute; + bottom: 90px; + right: 20px; + width: 180px; + height: 48px; + background: #28a745; + color: white; + border: none; + padding: 0 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + z-index: 10002; + transition: background-color 0.2s, transform 0.1s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-set-primary:hover { + background: #218838; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.lightbox-set-primary:active { + transform: translateY(0); +} + +.lightbox-set-primary:disabled { + background: #6c757d; + cursor: not-allowed; + opacity: 0.6; +} + +/* Delete Media Button */ +.lightbox-delete-media { + position: absolute; + bottom: 30px; + left: 20px; + width: 180px; + height: 48px; + background: #dc3545; + color: white; + border: none; + padding: 0 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + z-index: 10002; + transition: background-color 0.2s, transform 0.1s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-delete-media:hover { + background: #c82333; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.lightbox-delete-media:active { + transform: translateY(0); +} + +.lightbox-delete-media:disabled { + background: #6c757d; + cursor: not-allowed; + opacity: 0.6; +} + +/* Add Media Button */ +.lightbox-add-media { + position: absolute; + bottom: 30px; + right: 20px; + width: 180px; + height: 48px; + background: #28a745; + color: white; + border: none; + padding: 0 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + z-index: 10002; + transition: background-color 0.2s, transform 0.1s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-add-media:hover { + background: #218838; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.lightbox-add-media:active { + transform: translateY(0); +} + /* Mobile Responsiveness */ @media (max-width: 768px) { .lightbox-container { @@ -268,9 +423,37 @@ bottom: 10px; } - .lightbox-thumbnail { - width: 60px; - height: 45px; + .lightbox-youtube-container { + width: calc(100vw - 40px); + height: calc((100vw - 40px) * 9 / 16); + max-height: none; + } + + .lightbox-set-primary { + bottom: 85px; + right: 10px; + width: 160px; + height: 44px; + padding: 0 16px; + font-size: 13px; + } + + .lightbox-delete-media { + bottom: 30px; + left: 10px; + width: 160px; + height: 44px; + padding: 0 16px; + font-size: 13px; + } + + .lightbox-add-media { + bottom: 30px; + right: 10px; + width: 160px; + height: 44px; + padding: 0 16px; + font-size: 13px; } } diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx index b5ae9478..63fa4587 100644 --- a/frontend/src/components/Lightbox.jsx +++ b/frontend/src/components/Lightbox.jsx @@ -1,13 +1,19 @@ import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import MediaUploadModal from './MediaUploadModal'; import './Lightbox.css'; /** * Lightbox Component * Full-screen media viewer with prev/next navigation * Supports images, videos, and YouTube embeds + * Uses React Portal to render outside sidebar DOM */ -function Lightbox({ media, initialIndex = 0, onClose, poiId }) { +function Lightbox({ media, initialIndex = 0, onClose, poiId, user, onMediaUpdate }) { const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const [settingPrimary, setSettingPrimary] = useState(false); const handlePrevious = useCallback(() => { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : media.length - 1)); @@ -45,6 +51,96 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId }) { const currentMedia = media[currentIndex]; + const handleUploadSuccess = () => { + setUploadModalOpen(false); + if (onMediaUpdate) { + onMediaUpdate(); + } + }; + + const handleDelete = async () => { + if (!window.confirm('Are you sure you want to delete this image?')) { + return; + } + + setDeleting(true); + try { + const response = await fetch(`/api/pois/${poiId}/media/${currentMedia.id}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete'); + } + + // If this is the last image, close lightbox + if (media.length === 1) { + onClose(); + if (onMediaUpdate) { + onMediaUpdate(); + } + return; + } + + // Move to next image, or previous if we're at the end + let newIndex = currentIndex; + if (currentIndex >= media.length - 1) { + newIndex = Math.max(0, currentIndex - 1); + } + + // Update the index first + setCurrentIndex(newIndex); + + // Refresh media list + if (onMediaUpdate) { + onMediaUpdate(); + } + } catch (error) { + console.error('Delete failed:', error); + alert('Failed to delete image: ' + error.message); + } finally { + setDeleting(false); + } + }; + + const handleSetPrimary = async () => { + if (!window.confirm('Set this as the primary image? The current primary will become a gallery image.')) { + return; + } + + const currentMediaId = currentMedia.id; + setSettingPrimary(true); + try { + const response = await fetch(`/api/pois/${poiId}/media/${currentMedia.id}/set-primary`, { + method: 'PATCH', + credentials: 'include' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to set primary'); + } + + // Refresh media and stay on the same image (it will move to index 0 as primary) + if (onMediaUpdate) { + onMediaUpdate(); + } + + // Emit event to refresh map markers with new primary image + window.dispatchEvent(new CustomEvent('poi-updated', { detail: { poiId } })); + + // The image we just set as primary will now be at index 0 + setCurrentIndex(0); + } catch (error) { + console.error('Set primary failed:', error); + alert('Failed to set as primary: ' + error.message); + } finally { + setSettingPrimary(false); + } + }; + const renderMedia = () => { if (currentMedia.media_type === 'youtube') { return ( @@ -81,7 +177,7 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId }) { } }; - return ( + return createPortal(
e.stopPropagation()}> {/* Close Button */} @@ -125,6 +221,20 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId }) {
)} + {/* Pending indicator for user's own uploads */} + {currentMedia.moderation_status === 'pending' && ( +
+ ⏱ Pending Review +
+ )} + + {/* Primary image indicator */} + {currentMedia.role === 'primary' && ( +
+ ⭐ Primary Image +
+ )} + {/* Counter */}
{currentIndex + 1} / {media.length} @@ -158,12 +268,71 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId }) { {item.media_type === 'youtube' && (
YT
)} + {item.moderation_status === 'pending' && ( +
+ )}
))}
)} + + {/* Set as Primary button for admins on non-primary published images */} + {user && (user.role === 'admin' || user.role === 'media_admin') && + currentMedia.role !== 'primary' && + ['published', 'auto_approved'].includes(currentMedia.moderation_status) && ( + + )} + + {/* Delete button for user's own uploads or admins */} + {user && (currentMedia.uploaded_by_user || user.role === 'admin' || user.role === 'media_admin') && ( + + )} + + {/* Add Photo/Video button for authenticated users */} + {user && ( + + )} - + + {/* Upload Modal */} + {uploadModalOpen && ( + setUploadModalOpen(false)} + onSuccess={handleUploadSuccess} + /> + )} + , + document.body ); } diff --git a/frontend/src/components/MediaUploadModal.css b/frontend/src/components/MediaUploadModal.css index 5a600134..e024c75f 100644 --- a/frontend/src/components/MediaUploadModal.css +++ b/frontend/src/components/MediaUploadModal.css @@ -257,12 +257,12 @@ } .btn-primary { - background: #007bff; + background: #28a745; color: white; } .btn-primary:hover:not(:disabled) { - background: #0056b3; + background: #218838; } .btn-primary:disabled { diff --git a/frontend/src/components/MediaUploadModal.jsx b/frontend/src/components/MediaUploadModal.jsx index 6fb8ae2a..29c74576 100644 --- a/frontend/src/components/MediaUploadModal.jsx +++ b/frontend/src/components/MediaUploadModal.jsx @@ -23,11 +23,21 @@ function MediaUploadModal({ poiId, onClose, onSuccess }) { if (!file) return; const isVideo = activeTab === 'video'; + const fileName = file.name.toLowerCase(); + + // Check both MIME type and file extension for better compatibility const allowedTypes = isVideo ? ['video/mp4', 'video/webm', 'video/quicktime'] : ['image/jpeg', 'image/png', 'image/webp']; - if (!allowedTypes.includes(file.type)) { + const allowedExtensions = isVideo + ? ['.mp4', '.webm', '.mov'] + : ['.jpg', '.jpeg', '.png', '.webp']; + + const hasValidType = allowedTypes.includes(file.type); + const hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext)); + + if (!hasValidType && !hasValidExtension) { const expected = isVideo ? 'MP4, WebM, or MOV' : 'JPEG, PNG, or WebP'; setError(`Please select a ${expected} file`); return; @@ -180,8 +190,8 @@ function MediaUploadModal({ poiId, onClose, onSuccess }) { type="file" accept={ activeTab === 'video' - ? 'video/mp4,video/webm,video/quicktime' - : 'image/jpeg,image/png,image/webp' + ? 'video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov' + : 'image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp' } onChange={(e) => handleFileSelect(e.target.files[0])} style={{ display: 'none' }} diff --git a/frontend/src/components/ModerationInbox.jsx b/frontend/src/components/ModerationInbox.jsx index ec09a724..eef59a9f 100644 --- a/frontend/src/components/ModerationInbox.jsx +++ b/frontend/src/components/ModerationInbox.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; +import Lightbox from './Lightbox'; const FIELD_CONFIGS = { news: [ @@ -28,6 +29,7 @@ const FIELD_CONFIGS = { }; function ModerationInbox({ onCountChange }) { + console.log('[ModerationInbox] Mounted with onCountChange:', !!onCountChange); const [queue, setQueue] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -53,6 +55,10 @@ function ModerationInbox({ onCountChange }) { const [addingUrl, setAddingUrl] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchInput, setSearchInput] = useState(''); + const [lightboxMedia, setLightboxMedia] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(0); + const [lightboxPoiId, setLightboxPoiId] = useState(null); + const [user, setUser] = useState(null); const LIMIT = 20; const fetchQueue = useCallback(async () => { @@ -90,27 +96,108 @@ function ModerationInbox({ onCountChange }) { .then(r => r.ok ? r.json() : []) .then(data => setPois(Array.isArray(data) ? data : [])) .catch(() => setPois([])); + + fetch('/api/user', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(data => setUser(data)) + .catch(() => setUser(null)); }, []); const notify = (type, message) => setNotification({ type, message }); + const getThumbnailUrl = (item) => { + if (!item.media_type) return null; + + if (item.media_type === 'youtube') { + // Extract video ID from YouTube URL + const videoId = item.source_url?.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/)?.[1]; + return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null; + } else if (item.image_server_asset_id && (item.media_type === 'image' || item.media_type === 'video')) { + return `/api/assets/${item.image_server_asset_id}/thumbnail`; + } + return null; + }; + + const handleOpenLightbox = async (item) => { + if (!item.poi_id) return; + + try { + // Fetch all media for this POI + const response = await fetch(`/api/pois/${item.poi_id}/media`, { credentials: 'include' }); + if (!response.ok) return; + + const data = await response.json(); + const allMedia = data.all_media || []; + + // Find the index of the clicked item + const index = allMedia.findIndex(m => m.id === item.id); + + setLightboxMedia(allMedia); + setLightboxIndex(index >= 0 ? index : 0); + setLightboxPoiId(item.poi_id); + } catch (err) { + console.error('Failed to load media for lightbox:', err); + } + }; + + const handleLightboxClose = () => { + setLightboxMedia(null); + setLightboxIndex(0); + setLightboxPoiId(null); + }; + + const handleMediaUpdate = () => { + fetchQueue(); + if (lightboxPoiId) { + // Refresh lightbox media + fetch(`/api/pois/${lightboxPoiId}/media`, { credentials: 'include' }) + .then(r => r.json()) + .then(data => setLightboxMedia(data.all_media || [])) + .catch(err => console.error('Failed to refresh lightbox media:', err)); + } + }; + const handleApprove = async (type, id) => { try { + // Find the item to get its POI ID before approval + const item = queue.find(q => q.content_type === type && q.id === id); + console.log('[Moderation] Approving:', { type, id, item }); const response = await fetch('/api/admin/moderation/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ type, id }) }); - if (response.ok) { notify('success', `${type} #${id} approved`); fetchQueue(); if (onCountChange) onCountChange(); } + if (response.ok) { + notify('success', `${type} #${id} approved`); + fetchQueue(); + console.log('[Moderation] Calling onCountChange:', !!onCountChange); + if (onCountChange) onCountChange(); + // Emit event to refresh media for this POI + if (type === 'photo' && item?.poi_id) { + console.log('[Moderation] Emitting poi-media-updated event for POI', item.poi_id); + window.dispatchEvent(new CustomEvent('poi-media-updated', { detail: { poiId: item.poi_id } })); + // Also emit event to refresh map markers (in case this was a primary image change) + window.dispatchEvent(new CustomEvent('poi-updated', { detail: { poiId: item.poi_id } })); + } + } } catch (err) { notify('error', err.message); } }; const handleReject = async (type, id) => { try { + const item = queue.find(q => q.content_type === type && q.id === id); const response = await fetch('/api/admin/moderation/reject', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ type, id, reason: '' }) }); - if (response.ok) { notify('success', `${type} #${id} rejected`); fetchQueue(); if (onCountChange) onCountChange(); } + if (response.ok) { + notify('success', `${type} #${id} rejected`); + fetchQueue(); + if (onCountChange) onCountChange(); + // Emit event to refresh media for this POI + if (type === 'photo' && item?.poi_id) { + window.dispatchEvent(new CustomEvent('poi-media-updated', { detail: { poiId: item.poi_id } })); + } + } } catch (err) { notify('error', err.message); } }; @@ -120,6 +207,14 @@ function ModerationInbox({ onCountChange }) { const [type, id] = key.split(':'); return { type, id: parseInt(id) }; }); + // Collect unique POI IDs for photo items + const photoPoiIds = new Set(); + items.forEach(({ type, id }) => { + if (type === 'photo') { + const item = queue.find(q => q.content_type === type && q.id === id); + if (item?.poi_id) photoPoiIds.add(item.poi_id); + } + }); try { const response = await fetch('/api/admin/moderation/bulk-approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -131,6 +226,10 @@ function ModerationInbox({ onCountChange }) { setSelectedItems(new Set()); fetchQueue(); if (onCountChange) onCountChange(); + // Emit events for all affected POIs + photoPoiIds.forEach(poiId => { + window.dispatchEvent(new CustomEvent('poi-media-updated', { detail: { poiId } })); + }); } } catch (err) { notify('error', err.message); } }; @@ -331,21 +430,36 @@ function ModerationInbox({ onCountChange }) { }; const handleSave = async (type, id) => { + console.log('[Moderation] Saving:', { type, id, edits: editFields }); + const item = queue.find(q => q.content_type === type && q.id === id); try { const response = await fetch('/api/admin/moderation/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ type, id, edits: editFields }) }); if (response.ok) { + console.log('[Moderation] Save successful'); notify('success', `${type} #${id} saved`); setEditingItem(null); setEditFields({}); fetchQueue(); + // Emit event to refresh media for this POI (in case caption or POI changed) + if (type === 'photo' && item?.poi_id) { + window.dispatchEvent(new CustomEvent('poi-media-updated', { detail: { poiId: item.poi_id } })); + // Also emit for the new POI if it changed + if (editFields.poi_id && editFields.poi_id !== item.poi_id) { + window.dispatchEvent(new CustomEvent('poi-media-updated', { detail: { poiId: editFields.poi_id } })); + } + } } else { const err = await response.json(); + console.error('[Moderation] Save failed:', err); notify('error', err.error || 'Save failed'); } - } catch (err) { notify('error', err.message); } + } catch (err) { + console.error('[Moderation] Save error:', err); + notify('error', err.message); + } }; const handleCreate = async () => { @@ -762,6 +876,64 @@ function ModerationInbox({ onCountChange }) { )} + {/* Thumbnail for media items */} + {item.content_type === 'photo' && getThumbnailUrl(item) && ( +
handleOpenLightbox(item)} + style={{ + width: '120px', + height: '90px', + borderRadius: '6px', + overflow: 'hidden', + cursor: 'pointer', + margin: '6px 0', + border: '1px solid #e0e0e0', + position: 'relative', + flexShrink: 0 + }} + > + {item.title + {item.media_type === 'video' && ( +
+ ▶ +
+ )} + {item.media_type === 'youtube' && ( +
+ YT +
+ )} +
+ )} + {/* Event dates */} {item.content_type === 'event' && (item.start_date || item.end_date) && (
@@ -776,16 +948,18 @@ function ModerationInbox({ onCountChange }) {

{item.description}

)} - setExpandedItem(isExpanded ? null : itemKey)} - style={{ color: '#4a7c23', fontSize: '0.8rem', cursor: 'pointer', textDecoration: 'none', fontWeight: 500 }}> - {isExpanded ? 'Show less' : 'Show more'} - + {item.description && item.description.length > 200 && ( + setExpandedItem(isExpanded ? null : itemKey)} + style={{ color: '#4a7c23', fontSize: '0.8rem', cursor: 'pointer', textDecoration: 'none', fontWeight: 500 }}> + {isExpanded ? 'Show less' : 'Show more'} + + )} {/* Source URL (expanded, read-only) */} {isExpanded && item.source_url && ( @@ -1009,6 +1183,18 @@ function ModerationInbox({ onCountChange }) { {notification.message}
)} + + {/* Lightbox */} + {lightboxMedia && ( + + )} ); } diff --git a/frontend/src/components/Mosaic.css b/frontend/src/components/Mosaic.css index a77d5ddb..b466cf5d 100644 --- a/frontend/src/components/Mosaic.css +++ b/frontend/src/components/Mosaic.css @@ -1,12 +1,13 @@ /* Mosaic Container */ .mosaic { width: 100%; + height: 200px; display: grid; gap: 4px; - border-radius: 8px; + border-radius: 0; overflow: hidden; cursor: pointer; - margin-bottom: 16px; + flex-shrink: 0; } /* Single image - full width */ @@ -81,6 +82,34 @@ background-color: rgba(0, 0, 0, 0.8); } +/* Primary Image Indicator */ +.mosaic-primary-indicator { + position: absolute; + top: 8px; + right: 8px; + font-size: 20px; + pointer-events: none; + filter: grayscale(100%) brightness(1.2); + opacity: 0.7; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +/* Pending Review Indicator */ +.mosaic-pending-indicator { + position: absolute; + top: 8px; + left: 8px; + background-color: rgba(255, 165, 0, 0.9); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + text-transform: uppercase; + letter-spacing: 0.5px; +} + /* More Photos Overlay */ .mosaic-more-overlay { position: absolute; diff --git a/frontend/src/components/Mosaic.jsx b/frontend/src/components/Mosaic.jsx index 70dbd695..cba972f4 100644 --- a/frontend/src/components/Mosaic.jsx +++ b/frontend/src/components/Mosaic.jsx @@ -7,7 +7,7 @@ import './Mosaic.css'; * Displays 1-3 images in a Facebook-style mosaic layout * Click opens lightbox with all media */ -function Mosaic({ media, poiId }) { +function Mosaic({ media, poiId, user, onMediaUpdate }) { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); @@ -57,10 +57,20 @@ function Mosaic({ media, poiId }) { {item.media_type === 'youtube' && (
- +
)} + {/* Primary indicator */} + {item.role === 'primary' && ( +
+ )} + {/* Pending indicator for user's own uploads */} + {item.moderation_status === 'pending' && ( +
+ Pending Review +
+ )} {/* Show count overlay on last image if there are more */} {index === 2 && media.length > 3 && (
@@ -77,6 +87,8 @@ function Mosaic({ media, poiId }) { initialIndex={lightboxIndex} onClose={handleCloseLightbox} poiId={poiId} + user={user} + onMediaUpdate={onMediaUpdate} /> )} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index e09d7f64..e985578c 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -209,121 +209,10 @@ function EditableCellSignal({ level, onChange }) { } // Read-only view component - works for both destinations and linear features -function ReadOnlyView({ destination, isLinearFeature, isAdmin, editMode, showImage = true, onShare, moreInfoLink, trailStatus = null, _showNpsMap, _onToggleNpsMap, onCollectStatus }) { - // Multi-media state - const [media, setMedia] = useState([]); - const [mediaLoading, setMediaLoading] = useState(false); - const [uploadModalOpen, setUploadModalOpen] = useState(false); - const [user, setUser] = useState(null); - - // Check authentication status - useEffect(() => { - fetch('/api/auth/status', { credentials: 'include' }) - .then(res => res.ok ? res.json() : null) - .then(data => setUser(data?.user || null)) - .catch(() => setUser(null)); - }, []); - - // Fetch media for this POI - useEffect(() => { - if (!destination?.id) return; - - setMediaLoading(true); - fetch(`/api/pois/${destination.id}/media`, { credentials: 'include' }) - .then(res => res.ok ? res.json() : { mosaic: [], all_media: [] }) - .then(data => { - setMedia(data.all_media || []); - }) - .catch(err => { - console.error('Failed to load media:', err); - setMedia([]); - }) - .finally(() => setMediaLoading(false)); - }, [destination?.id]); - - // Legacy: Use thumbnail service for backward compatibility - // Include updated_at for cache busting when image changes - const imageUrl = destination.has_primary_image - ? `/api/pois/${destination.id}/thumbnail?size=medium&v=${destination.updated_at || Date.now()}` - : null; - - // Get default thumbnail SVG path based on type - const getDefaultThumbnail = () => { - if (isLinearFeature) { - if (destination.feature_type === 'river') return '/icons/thumbnails/river.svg'; - if (destination.feature_type === 'boundary') return '/icons/thumbnails/boundary.svg'; - return '/icons/thumbnails/trail.svg'; - } - if (destination.poi_type === 'virtual') return '/icons/thumbnails/virtual.svg'; - // MTB trailheads are point POIs with status_url - if (destination.poi_type === 'point' && destination.status_url) return '/icons/thumbnails/trail.svg'; - return '/icons/thumbnails/destination.svg'; - }; - - const handleUploadSuccess = () => { - // Refresh media after successful upload - fetch(`/api/pois/${destination.id}/media`, { credentials: 'include' }) - .then(res => res.json()) - .then(data => setMedia(data.all_media || [])) - .catch(err => console.error('Failed to refresh media:', err)); - }; - +function ReadOnlyView({ destination, isLinearFeature, isAdmin, editMode, onShare, moreInfoLink, trailStatus = null, _showNpsMap, _onToggleNpsMap, onCollectStatus }) { return (
- {/* Multi-media section */} - {showImage && ( -
- {media.length > 0 ? ( - - ) : !mediaLoading && destination.has_primary_image ? ( - // Fallback: Show single image if media endpoint returned empty but has_primary_image is true -
- {destination.name} -
- ) : !mediaLoading ? ( - // No images - show default thumbnail -
- {destination.name} -
- ) : ( - // Loading state -
-

Loading media...

-
- )} - - {/* Add Media button for authenticated users */} - {user && ( - - )} -
- )} - - {/* Upload Modal */} - {uploadModalOpen && ( - setUploadModalOpen(false)} - onSuccess={handleUploadSuccess} - /> - )}
@@ -482,7 +371,7 @@ function ReadOnlyView({ destination, isLinearFeature, isAdmin, editMode, showIma } // Edit view component - works for both destinations and linear features -function EditView({ destination, editedData, setEditedData, onSave, onCancel, onDelete, saving, deleting, onPreviewCoordsChange, isNewPOI, isNewOrganization, _onImageUpdate, isLinearFeature, showImage = true }) { +function EditView({ destination, editedData, setEditedData, onSave, onCancel, onDelete, saving, deleting, onPreviewCoordsChange, isNewPOI, isNewOrganization, _onImageUpdate, isLinearFeature }) { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [aiError, setAiError] = useState(null); // Prompt editor modal state @@ -508,6 +397,23 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on // Pending image state (staging area for image uploads until save) const [pendingImage, setPendingImage] = useState(null); + // User state for admin checks + const [user, setUser] = useState(null); + + // Check authentication status + useEffect(() => { + fetch('/api/auth/status', { credentials: 'include' }) + .then(res => res.ok ? res.json() : null) + .then(data => setUser(data?.user || null)) + .catch(() => setUser(null)); + }, []); + + // Callback for media updates from ImageUploader (no-op, just for consistency) + const handleMediaUpdate = () => { + // In edit mode, media updates will be handled by the parent refresh + // This is just a placeholder for the ImageUploader interface + }; + // Handle save with pending image processing const handleSaveWithImage = async () => { if (!destination?.id) { @@ -874,6 +780,9 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on updatedAt={editedData.updated_at} disabled={saving} isVirtualPoi={destination?.poi_type === 'virtual'} + user={user} + poiId={destination.id} + onMediaUpdate={handleMediaUpdate} /> ) : (
@@ -2785,7 +2694,7 @@ function TrailStatus({ poiId, _poiName, isAdmin, editMode, _selectedFromMtbList, ); } -function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, onClose, isAdmin, editMode, onDestinationUpdate, onDestinationDelete, onSaveNewPOI, onCancelNewPOI, onSaveNewOrganization, onCancelNewOrganization, previewCoords, onPreviewCoordsChange, linearFeature, onLinearFeatureUpdate, onLinearFeatureDelete, onNavigate, currentIndex, totalCount, poiNavigationList, associations, allDestinations, allLinearFeatures, allVirtualPois, onSelectDestination, onSelectLinearFeature, onAssociationsChanged, onStartDrawingAssociations, isInMtbMode, selectedFromMtbList, mtbTrailsList, currentMtbIndex, onNavigateMtbTrail, onBackToMtbList, showNpsMap, onToggleNpsMap }) { +function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, onClose, isAdmin, user, editMode, onDestinationUpdate, onDestinationDelete, onSaveNewPOI, onCancelNewPOI, onSaveNewOrganization, onCancelNewOrganization, previewCoords, onPreviewCoordsChange, linearFeature, onLinearFeatureUpdate, onLinearFeatureDelete, onNavigate, currentIndex, totalCount, poiNavigationList, associations, allDestinations, allLinearFeatures, allVirtualPois, onSelectDestination, onSelectLinearFeature, onAssociationsChanged, onStartDrawingAssociations, isInMtbMode, selectedFromMtbList, mtbTrailsList, currentMtbIndex, onNavigateMtbTrail, onBackToMtbList, showNpsMap, onToggleNpsMap }) { const [isEditing, setIsEditing] = useState(false); const [editedData, setEditedData] = useState({}); const [saving, setSaving] = useState(false); @@ -2795,6 +2704,7 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on const [showAssociationsModal, setShowAssociationsModal] = useState(false); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [pendingImage, setPendingImage] = useState(null); + const [uploadModalOpen, setUploadModalOpen] = useState(false); const [, setNewsCount] = useState(0); const [, setEventsCount] = useState(0); const [, setCollectResult] = useState(null); @@ -2815,6 +2725,83 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on } }, [newOrganization]); + // Media state for top-level image/mosaic display + const [media, setMedia] = useState([]); + const [mediaLoading, setMediaLoading] = useState(false); + + // Fetch media for destination/linear feature + useEffect(() => { + const poiId = destination?.id || linearFeature?.id; + if (!poiId) return; + + setMediaLoading(true); + fetch(`/api/pois/${poiId}/media`, { credentials: 'include' }) + .then(res => res.ok ? res.json() : { all_media: [] }) + .then(data => { + setMedia(data.all_media || []); + setMediaLoading(false); + }) + .catch(err => { + console.error('Failed to load media:', err); + setMedia([]); + setMediaLoading(false); + }); + }, [destination?.id, linearFeature?.id]); + + // Listen for media updates from moderation queue + useEffect(() => { + const poiId = destination?.id || linearFeature?.id; + if (!poiId) return; + + const handleMediaUpdateEvent = (event) => { + if (event.detail.poiId === poiId) { + console.log('[Sidebar] POI media updated for', poiId, '- refreshing...'); + // Refresh media list + fetch(`/api/pois/${poiId}/media`, { credentials: 'include' }) + .then(res => res.json()) + .then(data => setMedia(data.all_media || [])) + .catch(err => console.error('[Sidebar] Failed to refresh media:', err)); + + // Refresh POI data to update has_primary_image flag + fetch(`/api/pois/${poiId}`, { credentials: 'include' }) + .then(res => res.json()) + .then(data => { + if (destination && onDestinationUpdate) { + onDestinationUpdate(data); + } + }) + .catch(err => console.error('[Sidebar] Failed to refresh POI:', err)); + } + }; + + window.addEventListener('poi-media-updated', handleMediaUpdateEvent); + return () => window.removeEventListener('poi-media-updated', handleMediaUpdateEvent); + }, [destination?.id, linearFeature?.id]); + + // Callback for media updates from ImageUploader or Mosaic + const handleMediaUpdate = () => { + // Refresh media after upload + const poiId = destination?.id || linearFeature?.id; + if (!poiId) return; + + // Refresh media list + fetch(`/api/pois/${poiId}/media`, { credentials: 'include' }) + .then(res => res.json()) + .then(data => setMedia(data.all_media || [])) + .catch(err => console.error('Failed to refresh media:', err)); + + // Refresh POI data to update has_primary_image flag + if (destination && onDestinationUpdate) { + fetch(`/api/pois/${poiId}`, { credentials: 'include' }) + .then(res => res.json()) + .then(data => onDestinationUpdate(data)) + .catch(err => console.error('Failed to refresh POI:', err)); + } + + // Emit event to refresh moderation count (photo uploads go to pending) + window.dispatchEvent(new CustomEvent('moderation-count-changed')); + }; + // Determine which POI we're showing const activePoi = destination || linearFeature; @@ -3396,7 +3383,7 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on
- {/* Image - always shown at top for all tabs */} + {/* Media section - Mosaic in view mode, ImageUploader in edit mode */} {isEditing && linearFeature?.id ? ( - ) : ( + ) : media.length > 0 ? ( + + ) : !mediaLoading && linearFeature?.has_primary_image ? (
- {linearImageUrl ? ( - {linearFeature?.name} - ) : ( - {linearFeature?.name} - )} - {/* Navigation chevrons on image - mobile only */} - {isMobile && onNavigate && poiNavigationList && poiNavigationList.length > 1 && ( - <> - {currentIndex > 0 && ( - - )} - {currentIndex < poiNavigationList.length - 1 && ( - - )} - - )} + {linearFeature?.name}
- )} + ) : user && linearFeature?.id && !mediaLoading ? ( +
+ +
+ ) : null} {/* Sidebar Tabs - same as destinations */}
@@ -3507,7 +3466,6 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on deleting={deleting} isNewPOI={false} isLinearFeature={true} - showImage={false} onImageUpdate={(hasImage, driveFileId) => { if (onLinearFeatureUpdate) { onLinearFeatureUpdate({ @@ -3523,7 +3481,6 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on isLinearFeature={true} isAdmin={isAdmin} editMode={editMode} - showImage={false} onShare={() => setShowShareModal(true)} moreInfoLink={linearFeature.more_info_link} trailStatus={trailStatus} @@ -3718,7 +3675,7 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on
- {/* Image - always shown at top for all tabs */} + {/* Media section - Mosaic in view mode, ImageUploader in edit mode */} {isEditing && destination?.id ? ( - ) : ( + ) : media.length > 0 ? ( + + ) : !mediaLoading && destination?.has_primary_image ? (
- {imageUrl ? ( - {destination?.name} - ) : ( - {destination?.name} - )} - {/* Navigation chevrons on image - mobile only */} - {isMobile && onNavigate && poiNavigationList && poiNavigationList.length > 1 && ( - <> - {currentIndex > 0 && ( - - )} - {currentIndex < poiNavigationList.length - 1 && ( - - )} - - )} + {destination?.name}
- )} + ) : user && destination?.id && !mediaLoading ? ( +
+ +
+ ) : null} {/* Sidebar Tabs - always shown */}
@@ -3999,6 +3929,17 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on editMode={editMode} onAssociationsChanged={onAssociationsChanged} /> + + {uploadModalOpen && destination?.id && ( + setUploadModalOpen(false)} + onSuccess={() => { + setUploadModalOpen(false); + handleMediaUpdate(); + }} + /> + )}
); } diff --git a/frontend/src/components/SyncSettings.jsx b/frontend/src/components/SyncSettings.jsx index bc80e97b..efcfd93a 100644 --- a/frontend/src/components/SyncSettings.jsx +++ b/frontend/src/components/SyncSettings.jsx @@ -323,6 +323,14 @@ function SyncSettings({ onDataRefresh, onNavigateToJobs }) { {error &&
{error}
} {message &&
{message}
} + {/* Drive access prompt - shown when admin lacks Drive credentials */} + {syncStatus && !syncStatus.drive_access_verified && ( +
+

⚠️ Drive access required for backup/restore operations.

+ Grant Drive Access +
+ )} +

Backup & Restore

diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 80a774c3..e417764b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,9 +5,7 @@ import App from './App'; import './App.css'; ReactDOM.createRoot(document.getElementById('root')).render( - - - - - + + + ); From 352c55419b73980db228ec713f0ba141b7df8597 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 00:29:55 -0400 Subject: [PATCH 2/7] fix: restore POI navigation chevron buttons on mobile The image navigation buttons (grey chevrons on mobile) were accidentally removed when unifying media handling across POI types. These buttons allow users to navigate between POIs in the list on mobile devices. Fixes failing UI tests: - should navigate POIs using grey chevron buttons - should prevent double navigation on rapid button clicks --- frontend/src/components/Sidebar.jsx | 210 +++++++++++++++++++--------- 1 file changed, 147 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index e985578c..b7a9205f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -3384,38 +3384,80 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on
{/* Media section - Mosaic in view mode, ImageUploader in edit mode */} - {isEditing && linearFeature?.id ? ( - - ) : media.length > 0 ? ( - - ) : !mediaLoading && linearFeature?.has_primary_image ? ( -
- {linearFeature?.name} + {isEditing && linearFeature?.id ? ( + -
- ) : user && linearFeature?.id && !mediaLoading ? ( -
- -
- ) : null} + ) : media.length > 0 ? ( + + ) : !mediaLoading && linearFeature?.has_primary_image ? ( +
+ {linearFeature?.name} +
+ ) : user && linearFeature?.id && !mediaLoading ? ( +
+ +
+ ) : null} + + {/* Navigation chevrons on image - mobile only */} + {isMobile && !isEditing && onNavigate && poiNavigationList && poiNavigationList.length > 1 && ( + <> + {currentIndex > 0 && ( + + )} + {currentIndex < poiNavigationList.length - 1 && ( + + )} + + )} +
{/* Sidebar Tabs - same as destinations */}
@@ -3676,39 +3718,81 @@ function Sidebar({ destination, isNewPOI, newOrganization, isNewOrganization, on
{/* Media section - Mosaic in view mode, ImageUploader in edit mode */} - {isEditing && destination?.id ? ( - - ) : media.length > 0 ? ( - - ) : !mediaLoading && destination?.has_primary_image ? ( -
- {destination?.name} + {isEditing && destination?.id ? ( + -
- ) : user && destination?.id && !mediaLoading ? ( -
- -
- ) : null} + ) : media.length > 0 ? ( + + ) : !mediaLoading && destination?.has_primary_image ? ( +
+ {destination?.name} +
+ ) : user && destination?.id && !mediaLoading ? ( +
+ +
+ ) : null} + + {/* Navigation chevrons on image - mobile only */} + {isMobile && !isEditing && onNavigate && poiNavigationList && poiNavigationList.length > 1 && ( + <> + {currentIndex > 0 && ( + + )} + {currentIndex < poiNavigationList.length - 1 && ( + + )} + + )} +
{/* Sidebar Tabs - always shown */}
From 1f0e1ea1d1d406b34e6a62267a9710ab5d4e9030 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 00:59:17 -0400 Subject: [PATCH 3/7] chore: remove verbose comments for Gourmand compliance Removed redundant comments that restate what the code obviously does. Gourmand correctly flagged these as adding no information beyond the code itself. --- frontend/src/components/Lightbox.jsx | 8 -------- frontend/src/components/MediaUploadModal.jsx | 1 - 2 files changed, 9 deletions(-) diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx index 63fa4587..3ad3f37b 100644 --- a/frontend/src/components/Lightbox.jsx +++ b/frontend/src/components/Lightbox.jsx @@ -75,7 +75,6 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId, user, onMediaUpdate throw new Error(error.error || 'Failed to delete'); } - // If this is the last image, close lightbox if (media.length === 1) { onClose(); if (onMediaUpdate) { @@ -84,16 +83,13 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId, user, onMediaUpdate return; } - // Move to next image, or previous if we're at the end let newIndex = currentIndex; if (currentIndex >= media.length - 1) { newIndex = Math.max(0, currentIndex - 1); } - // Update the index first setCurrentIndex(newIndex); - // Refresh media list if (onMediaUpdate) { onMediaUpdate(); } @@ -123,15 +119,11 @@ function Lightbox({ media, initialIndex = 0, onClose, poiId, user, onMediaUpdate throw new Error(error.error || 'Failed to set primary'); } - // Refresh media and stay on the same image (it will move to index 0 as primary) if (onMediaUpdate) { onMediaUpdate(); } - // Emit event to refresh map markers with new primary image window.dispatchEvent(new CustomEvent('poi-updated', { detail: { poiId } })); - - // The image we just set as primary will now be at index 0 setCurrentIndex(0); } catch (error) { console.error('Set primary failed:', error); diff --git a/frontend/src/components/MediaUploadModal.jsx b/frontend/src/components/MediaUploadModal.jsx index 29c74576..e5493862 100644 --- a/frontend/src/components/MediaUploadModal.jsx +++ b/frontend/src/components/MediaUploadModal.jsx @@ -25,7 +25,6 @@ function MediaUploadModal({ poiId, onClose, onSuccess }) { const isVideo = activeTab === 'video'; const fileName = file.name.toLowerCase(); - // Check both MIME type and file extension for better compatibility const allowedTypes = isVideo ? ['video/mp4', 'video/webm', 'video/quicktime'] : ['image/jpeg', 'image/png', 'image/webp']; From 4869a51cd0ab3456027e07ec1b8d0cf25d136166 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 01:03:07 -0400 Subject: [PATCH 4/7] chore: remove AI-generated summary litter files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted 12 AI-generated status report files that Gourmand correctly identified as providing zero value. These files clutter the repository root and duplicate information already available in git history and the issue tracker. Per Gourmand guidance: - Progress history → git commit messages - Current status → issue tracker - Documentation → README.md and docs/ Files removed: - DEPLOYMENT_GUIDE.md - DEPLOYMENT_VERIFICATION_CHECKLIST.md - EXEC_SUMMARY.md - HANDOFF_SUMMARY.md - NEXT_STEPS.md - PACKAGE_STRUCTURE.md - PROD_FIX_QUICKREF.md - PROD_ISSUE_FLOWCHART.md - PROD_TROUBLESHOOT.md - PRODUCTION_INCIDENT_README.md - README_PRODUCTION.md - TROUBLESHOOTING_PACKAGE_INDEX.md --- DEPLOYMENT_GUIDE.md | 436 ------------------------- DEPLOYMENT_VERIFICATION_CHECKLIST.md | 381 ---------------------- EXEC_SUMMARY.md | 249 -------------- HANDOFF_SUMMARY.md | 424 ------------------------ NEXT_STEPS.md | 399 ----------------------- PACKAGE_STRUCTURE.md | 405 ----------------------- PRODUCTION_INCIDENT_README.md | 331 ------------------- PROD_FIX_QUICKREF.md | 255 --------------- PROD_ISSUE_FLOWCHART.md | 305 ------------------ PROD_TROUBLESHOOT.md | 345 -------------------- README_PRODUCTION.md | 368 --------------------- TROUBLESHOOTING_PACKAGE_INDEX.md | 466 --------------------------- backend/config/passport.js | 3 +- backend/routes/auth.js | 55 ++-- 14 files changed, 35 insertions(+), 4387 deletions(-) delete mode 100644 DEPLOYMENT_GUIDE.md delete mode 100644 DEPLOYMENT_VERIFICATION_CHECKLIST.md delete mode 100644 EXEC_SUMMARY.md delete mode 100644 HANDOFF_SUMMARY.md delete mode 100644 NEXT_STEPS.md delete mode 100644 PACKAGE_STRUCTURE.md delete mode 100644 PRODUCTION_INCIDENT_README.md delete mode 100644 PROD_FIX_QUICKREF.md delete mode 100644 PROD_ISSUE_FLOWCHART.md delete mode 100644 PROD_TROUBLESHOOT.md delete mode 100644 README_PRODUCTION.md delete mode 100644 TROUBLESHOOTING_PACKAGE_INDEX.md diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md deleted file mode 100644 index 9100be90..00000000 --- a/DEPLOYMENT_GUIDE.md +++ /dev/null @@ -1,436 +0,0 @@ -# Deployment Guide - Roots of The Valley - -**Target Server:** lotor.dc3.crunchtools.com:22422 -**Service:** rootsofthevalley.org -**Container Registry:** quay.io/crunchtools/rotv:latest - ---- - -## Quick Deployment (Standard Process) - -### Prerequisites -- [ ] PR merged to master -- [ ] GitHub Actions build completed successfully -- [ ] All tests passing (including integration tests) -- [ ] No blocking issues identified - -### Deployment Steps - -```bash -# 1. SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# 2. Backup database -mkdir -p /root/backups -podman exec rootsofthevalley.org pg_dump -U postgres rotv > \ - /root/backups/rotv_$(date +%Y%m%d_%H%M%S).sql - -# 3. Pull latest image -podman pull quay.io/crunchtools/rotv:latest - -# 4. Restart service -systemctl restart rootsofthevalley.org - -# 5. Verify deployment (30-second health check) -sleep 10 -systemctl status rootsofthevalley.org -curl -sf https://rootsofthevalley.org/api/health && echo "✅ Healthy" || echo "❌ Failed" -``` - -### Post-Deployment Verification - -```bash -# Option 1: Automated verification (recommended) -bash scripts/post-deployment-report.sh - -# Option 2: Run smoke tests via GitHub Actions -gh workflow run smoke-test.yml - -# Option 3: Manual verification -curl https://rootsofthevalley.org/api/pois/1/media | jq -``` - ---- - -## Deployment with Migrations (e.g., PR #182) - -When deploying features that include database migrations: - -### Pre-Deployment Checklist -- [ ] Identify all migrations in PR - - SQL migrations: `backend/migrations/*.sql` - - Node.js scripts: `backend/scripts/*.js` -- [ ] Review migration order and dependencies -- [ ] Check for data migration scripts -- [ ] Verify rollback procedure - -### Deployment Steps - -```bash -# 1. SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# 2. Backup database (CRITICAL for migrations) -mkdir -p /root/backups -BACKUP_FILE="/root/backups/rotv_$(date +%Y%m%d_%H%M%S).sql" -podman exec rootsofthevalley.org pg_dump -U postgres rotv > $BACKUP_FILE -ls -lh $BACKUP_FILE # Verify backup created - -# 3. Apply SQL migrations (in order) -# Example: For PR #182 -podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -f /app/migrations/015_add_poi_media.sql - -podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -f /app/migrations/016_fix_poi_media_constraints.sql - -# 4. Run data migration scripts -# Example: For PR #182 -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - -# 5. Verify migrations applied -bash scripts/verify-migrations.sh - -# 6. Pull latest image -podman pull quay.io/crunchtools/rotv:latest - -# 7. Restart service -systemctl restart rootsofthevalley.org -sleep 10 - -# 8. Verify deployment -bash scripts/post-deployment-report.sh -``` - -### Migration-Specific Verification - -```bash -# Verify table counts match expectations -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - 'pois' as table_name, COUNT(*)::text FROM pois UNION ALL - SELECT 'poi_media', COUNT(*)::text FROM poi_media UNION ALL - SELECT 'users', COUNT(*)::text FROM users; -" - -# Check for migration-specific data -# Example: PR #182 - verify primary images migrated -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT COUNT(*) as primary_images -FROM poi_media -WHERE role='primary' AND moderation_status IN ('published', 'auto_approved'); -" -``` - ---- - -## Rollback Procedures - -### Quick Rollback (Container Only) - -Use when new container has issues but database is fine: - -```bash -# Find previous image -podman images quay.io/crunchtools/rotv - -# Tag previous image as latest -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest - -# Restart -systemctl restart rootsofthevalley.org - -# Verify -curl -sf https://rootsofthevalley.org/api/health -``` - -### Full Rollback (Container + Database) - -Use when database migration failed or caused issues: - -```bash -# Find backup -ls -lht /root/backups/rotv_* | head -5 - -# Restore database -BACKUP_FILE="/root/backups/rotv_TIMESTAMP.sql" -podman exec -i rootsofthevalley.org psql -U postgres rotv < $BACKUP_FILE - -# Revert container -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest - -# Restart -systemctl restart rootsofthevalley.org - -# Verify rollback -curl -sf https://rootsofthevalley.org/api/health -systemctl status rootsofthevalley.org -``` - ---- - -## Troubleshooting Deployments - -### Service Won't Start - -```bash -# Check service status -systemctl status rootsofthevalley.org --no-pager -l - -# Check recent logs -journalctl -u rootsofthevalley.org --since "5 minutes ago" --no-pager - -# Check container logs -podman logs rootsofthevalley.org --tail 50 - -# Common issues: -# - Port already in use: Check with `ss -tlnp | grep :3000` -# - Database not ready: Check `podman exec rootsofthevalley.org systemctl status postgresql` -# - Migration failed: Check logs for SQL errors -``` - -### Images Not Loading (PR #182 Specific) - -```bash -# Quick diagnosis -bash scripts/diagnose-production.sh - -# Check if migration script was run -podman exec rootsofthevalley.org psql -U postgres -d rotv -c \ - "SELECT COUNT(*) FROM poi_media WHERE role='primary';" -# Should return > 0 - -# If 0, run migration script -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -systemctl restart rootsofthevalley.org -``` - -See **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** for comprehensive troubleshooting. - ---- - -## Monitoring After Deployment - -### First Hour (Critical) - -```bash -# Watch logs in real-time -journalctl -u rootsofthevalley.org -f - -# Check error count every 10 minutes -journalctl -u rootsofthevalley.org --since "10 minutes ago" | grep -i error | wc -l - -# Test key endpoints -curl -sf https://rootsofthevalley.org/api/health -curl -sf https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' -``` - -### First 24 Hours - -```bash -# Check every 4 hours -bash scripts/post-deployment-report.sh - -# Look for patterns in logs -journalctl -u rootsofthevalley.org --since "4 hours ago" | grep -i error | sort | uniq -c | sort -rn -``` - ---- - -## Deployment Checklist - -Use this checklist for every deployment: - -### Pre-Deployment -- [ ] PR reviewed and approved -- [ ] All CI/CD checks passing (build, tests, security) -- [ ] Deployment runbook reviewed (if feature has one) -- [ ] Backup strategy confirmed -- [ ] Rollback procedure understood -- [ ] Estimated downtime communicated (if any) - -### During Deployment -- [ ] Database backup created and verified -- [ ] All SQL migrations applied in order -- [ ] All data migration scripts executed -- [ ] Migration verification passed -- [ ] Latest container image pulled -- [ ] Service restarted successfully -- [ ] Service active and running - -### Post-Deployment -- [ ] Health endpoint responding -- [ ] Key API endpoints tested -- [ ] Feature-specific tests passed -- [ ] Post-deployment report generated -- [ ] No critical errors in logs -- [ ] Smoke tests passed (via GitHub Actions) -- [ ] Monitoring in place for next 24 hours - -See **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** for detailed checklist. - ---- - -## Automation Scripts - -| Script | Purpose | When to Use | -|--------|---------|-------------| -| `scripts/diagnose-production.sh` | Automated health check | Before and after deployment | -| `scripts/fix-production.sh` | Automated fix for common issues | When diagnosis finds issues | -| `scripts/verify-migrations.sh` | Verify all migrations applied | After migration deployment | -| `scripts/post-deployment-report.sh` | Generate deployment report | After every deployment | - -### Running Scripts - -```bash -# All scripts should be run on production server -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Make scripts executable (if needed) -chmod +x scripts/*.sh - -# Run diagnostic -bash scripts/diagnose-production.sh - -# Run migration verification -bash scripts/verify-migrations.sh - -# Generate post-deployment report -bash scripts/post-deployment-report.sh -``` - ---- - -## GitHub Actions Workflows - -### Smoke Tests (Manual Trigger) - -```bash -# Trigger smoke tests from local machine -gh workflow run smoke-test.yml - -# Monitor workflow -gh run watch - -# View results -gh run view -``` - -### Build Status - -```bash -# Check recent builds -gh run list --workflow=build.yml --limit 5 - -# View specific build -gh run view - -# Re-run failed build -gh run rerun -``` - ---- - -## Common Deployment Scenarios - -### Scenario 1: Simple Code Change (No Migrations) - -```bash -# 1. Wait for GHA build -gh run watch - -# 2. Deploy -ssh -p 22422 root@lotor.dc3.crunchtools.com -podman pull quay.io/crunchtools/rotv:latest -systemctl restart rootsofthevalley.org - -# 3. Verify -curl -sf https://rootsofthevalley.org/api/health && echo "✅ OK" -``` - -**Duration:** 2-3 minutes - -### Scenario 2: Database Migration (e.g., PR #182) - -```bash -# 1. Backup -ssh -p 22422 root@lotor.dc3.crunchtools.com -podman exec rootsofthevalley.org pg_dump -U postgres rotv > /root/backups/rotv_$(date +%Y%m%d_%H%M%S).sql - -# 2. Apply migrations -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/015_add_poi_media.sql -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/016_fix_poi_media_constraints.sql - -# 3. Verify migrations -bash scripts/verify-migrations.sh - -# 4. Deploy -podman pull quay.io/crunchtools/rotv:latest -systemctl restart rootsofthevalley.org - -# 5. Verify deployment -bash scripts/post-deployment-report.sh -``` - -**Duration:** 10-15 minutes - -### Scenario 3: Emergency Rollback - -```bash -# 1. Identify issue -journalctl -u rootsofthevalley.org --since "10 minutes ago" | grep -i error - -# 2. Rollback database (if needed) -podman exec -i rootsofthevalley.org psql -U postgres rotv < /root/backups/rotv_LATEST.sql - -# 3. Rollback container -podman images quay.io/crunchtools/rotv -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest - -# 4. Restart -systemctl restart rootsofthevalley.org - -# 5. Verify -curl -sf https://rootsofthevalley.org/api/health -``` - -**Duration:** 3-5 minutes - ---- - -## Reference Documents - -| Document | Purpose | -|----------|---------| -| **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** | Detailed post-deployment checklist | -| **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** | Comprehensive troubleshooting guide | -| **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** | Quick reference for common fixes | -| **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** | Visual diagrams for debugging | -| **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** | Incident response guide | -| **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** | Executive summary template | - ---- - -## Support & Escalation - -### For Deployment Issues -1. Check **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** -2. Run `scripts/diagnose-production.sh` -3. Review deployment logs -4. Consider rollback if critical - -### For Production Incidents -1. Follow **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** -2. Generate incident report -3. Document resolution -4. Create prevention measures - -### Contact -- **GitHub Issues:** https://github.com/crunchtools/rotv/issues -- **Deployment Owner:** Scott McCarty (@fatherlinux) - ---- - -**Last Updated:** 2026-04-04 -**Version:** 1.0 (based on learnings from PR #182 deployment) diff --git a/DEPLOYMENT_VERIFICATION_CHECKLIST.md b/DEPLOYMENT_VERIFICATION_CHECKLIST.md deleted file mode 100644 index 98a46766..00000000 --- a/DEPLOYMENT_VERIFICATION_CHECKLIST.md +++ /dev/null @@ -1,381 +0,0 @@ -# Deployment Verification Checklist - -**Purpose:** Run this checklist after EVERY production deployment to catch issues before they impact users. - -**Time Required:** 3-5 minutes - -**When to Run:** Immediately after `systemctl restart rootsofthevalley.org` - ---- - -## Pre-Deployment Checklist - -- [ ] PR merged to master -- [ ] GitHub Actions build completed successfully -- [ ] All tests passing (including integration tests) -- [ ] No security scan failures -- [ ] Database backup created -- [ ] All required migrations identified and ready -- [ ] Deployment runbook reviewed - ---- - -## Deployment Steps - -### 1. Database Migrations ✅ - -- [ ] All SQL migrations applied (check `backend/migrations/` directory) -- [ ] All Node.js migration scripts run (check `backend/scripts/` directory) -- [ ] Migration logs reviewed for errors -- [ ] Table counts verified (if applicable) - -**Commands:** -```bash -# Check which migrations exist -ls -1 backend/migrations/*.sql | tail -5 - -# Verify each migration was applied -# (Check timestamps, no errors in output) - -# For PR #182 specifically: -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "\d poi_media" -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM poi_media;" -``` - -### 2. Container Deployment ✅ - -- [ ] Latest image pulled from registry -- [ ] Image tag/SHA matches expected version -- [ ] Service restarted successfully -- [ ] Container running without immediate crashes - -**Commands:** -```bash -# Pull latest -podman pull quay.io/crunchtools/rotv:latest - -# Check image timestamp -podman images quay.io/crunchtools/rotv --format "{{.CreatedAt}}" - -# Restart service -systemctl restart rootsofthevalley.org -sleep 10 - -# Verify running -systemctl status rootsofthevalley.org --no-pager -``` - -### 3. Service Health ✅ - -- [ ] Service is active and running -- [ ] No errors in startup logs -- [ ] Process listening on expected port -- [ ] Container has been up for at least 30 seconds - -**Commands:** -```bash -# Check service status -systemctl is-active rootsofthevalley.org - -# Check recent logs -journalctl -u rootsofthevalley.org --since "1 minute ago" --no-pager | tail -30 - -# Check for errors -journalctl -u rootsofthevalley.org --since "1 minute ago" --no-pager | grep -i error - -# Verify port -ss -tlnp | grep :3000 -``` - ---- - -## Post-Deployment Verification - -### 4. Database Health ✅ - -- [ ] Database connection established -- [ ] All tables exist -- [ ] Expected record counts correct -- [ ] Recent migrations reflected in schema - -**Commands:** -```bash -# Test database connection -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT version();" - -# Check table count -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'public';" - -# Check specific critical tables (adjust for your deployment) -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - 'pois' as table_name, COUNT(*)::text as count FROM pois UNION ALL - SELECT 'poi_media', COUNT(*)::text FROM poi_media UNION ALL - SELECT 'poi_news', COUNT(*)::text FROM poi_news UNION ALL - SELECT 'users', COUNT(*)::text FROM users; -" -``` - -### 5. API Endpoints ✅ - -- [ ] Health endpoint responding -- [ ] Public API endpoints working -- [ ] Authentication endpoints working -- [ ] Admin API endpoints working (if applicable) - -**Commands:** -```bash -# Health check -curl -sf https://rootsofthevalley.org/api/health || echo "FAILED" - -# Test public API -curl -sf https://rootsofthevalley.org/api/pois?limit=1 | jq '.[0].id' || echo "FAILED" - -# Test media endpoint (PR #182 specific) -curl -sf https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' || echo "FAILED" - -# Test thumbnail endpoint -curl -I https://rootsofthevalley.org/api/pois/1/thumbnail 2>&1 | grep "HTTP" || echo "FAILED" - -# Test auth status -curl -sf https://rootsofthevalley.org/api/auth/status | jq '.authenticated' || echo "FAILED" -``` - -### 6. Frontend Functionality ✅ - -- [ ] Website loads without errors -- [ ] Map displays correctly -- [ ] POI markers visible -- [ ] Sidebar opens when clicking markers -- [ ] Images load correctly -- [ ] No console errors in browser DevTools - -**Manual Steps:** -1. Open https://rootsofthevalley.org in browser -2. Open DevTools (F12) → Console tab -3. Verify map loads and displays POIs -4. Click a POI marker -5. Verify sidebar opens with POI details -6. Verify images display (no "Failed to load image" errors) -7. Check console for JavaScript errors -8. Check Network tab for failed requests (red lines) - -### 7. Feature-Specific Tests ✅ - -**For PR #182 (Multi-Image POI):** - -- [ ] Mosaic displays for POIs with multiple images -- [ ] Single image displays for POIs with one image -- [ ] Default thumbnail for POIs with no images -- [ ] Lightbox opens when clicking mosaic -- [ ] Keyboard navigation works in lightbox (arrows, ESC) -- [ ] Upload modal opens for authenticated users -- [ ] Admin can see moderation queue - -**Commands:** -```bash -# Check media counts -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - media_type, - role, - COUNT(*) -FROM poi_media -GROUP BY media_type, role; -" - -# Check for any pending moderation items -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT COUNT(*) FROM moderation_queue WHERE content_type = 'photo'; -" -``` - -### 8. Performance & Resources ✅ - -- [ ] Response times acceptable (<1s for most endpoints) -- [ ] Memory usage normal -- [ ] CPU usage normal -- [ ] No resource exhaustion warnings - -**Commands:** -```bash -# Check response time -time curl -s https://rootsofthevalley.org/api/pois/1/media > /dev/null - -# Check container resource usage -podman stats --no-stream rootsofthevalley.org - -# Check system resources -free -h -df -h -``` - -### 9. Error Rates ✅ - -- [ ] No spike in 500 errors -- [ ] No spike in 404 errors -- [ ] No database connection errors -- [ ] No authentication failures - -**Commands:** -```bash -# Check for errors in last 5 minutes -journalctl -u rootsofthevalley.org --since "5 minutes ago" --no-pager | grep -c "error" - -# Check for specific error types -journalctl -u rootsofthevalley.org --since "5 minutes ago" --no-pager | grep -E "500|404|ECONNREFUSED|ETIMEDOUT" | wc -l - -# Sample recent logs -journalctl -u rootsofthevalley.org --since "5 minutes ago" --no-pager | tail -50 -``` - -### 10. External Dependencies ✅ - -- [ ] Image server connectivity verified -- [ ] Database server connectivity verified -- [ ] Any third-party APIs responding -- [ ] OAuth providers working (if applicable) - -**Commands:** -```bash -# Check image server -podman exec rootsofthevalley.org curl -sf http://10.89.1.100:8000/api/health || echo "FAILED" - -# Check IMAGE_SERVER_URL env var -podman exec rootsofthevalley.org printenv IMAGE_SERVER_URL - -# Test asset fetch -curl -I https://rootsofthevalley.org/api/assets/test-id/thumbnail 2>&1 | grep "HTTP" -# Should return 400 (bad request) which proves validation is working -``` - ---- - -## Rollback Decision Matrix - -| Symptom | Severity | Rollback? | -|---------|----------|-----------| -| Service won't start | 🔴 Critical | **YES** - Immediate rollback | -| Database migration failed | 🔴 Critical | **YES** - Restore backup | -| 500 errors on all endpoints | 🔴 Critical | **YES** - Immediate rollback | -| Images not loading | 🟡 Major | **NO** - Fix forward (run migration script) | -| Single feature broken | 🟡 Major | **MAYBE** - Evaluate impact | -| Minor UI glitch | 🟢 Minor | **NO** - Fix forward | -| Performance degradation | 🟡 Major | **MAYBE** - Monitor and decide | - ---- - -## Rollback Procedure (If Needed) - -### Quick Rollback (Container Only) -```bash -# Find previous working image -podman images quay.io/crunchtools/rotv - -# Tag previous image as latest -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest - -# Restart -systemctl restart rootsofthevalley.org -``` - -### Full Rollback (Container + Database) -```bash -# Find backup -ls -lht /root/backups/rotv_* | head -5 - -# Restore database -podman exec -i rootsofthevalley.org psql -U postgres rotv < /root/backups/rotv_TIMESTAMP.sql - -# Revert container (same as above) -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest -systemctl restart rootsofthevalley.org - -# Verify rollback worked -curl -sf https://rootsofthevalley.org/api/health -``` - ---- - -## Monitoring (First 24 Hours) - -### Hour 1 (Critical) -- [ ] Check logs every 10 minutes -- [ ] Monitor error rates -- [ ] Watch for user reports - -### Hours 2-6 (Important) -- [ ] Check logs every hour -- [ ] Review error patterns -- [ ] Test key workflows manually - -### Hours 7-24 (Normal) -- [ ] Check logs every 4 hours -- [ ] Review metrics/stats -- [ ] Note any anomalies - -**Commands:** -```bash -# Watch logs live -journalctl -u rootsofthevalley.org -f - -# Check error rate (run periodically) -journalctl -u rootsofthevalley.org --since "1 hour ago" --no-pager | grep -i error | wc -l - -# Check for specific issues -journalctl -u rootsofthevalley.org --since "1 hour ago" --no-pager | grep -i "failed to\|error\|exception" -``` - ---- - -## Sign-Off - -**Deployment Date:** ________________ -**Deployed By:** ________________ -**PR/Version:** ________________ - -**All checks passed:** ☐ YES ☐ NO -**Issues found:** ________________ -**Issues resolved:** ☐ YES ☐ NO ☐ N/A -**Rollback performed:** ☐ YES ☐ NO - -**Notes:** -``` -_________________________________________________________________________ -_________________________________________________________________________ -_________________________________________________________________________ -``` - ---- - -## Automation Ideas (Future) - -1. **Smoke Test Script** - - Runs all verification commands automatically - - Exits with error code if any check fails - - Can be triggered by CI/CD or manually - -2. **Health Dashboard** - - Real-time status of all checks - - Historical metrics - - Alert on anomalies - -3. **Automated Rollback** - - Detect critical failures automatically - - Trigger rollback without human intervention - - Send alerts to ops team - -4. **Canary Deployments** - - Deploy to subset of users first - - Monitor metrics before full rollout - - Auto-rollback if issues detected - ---- - -## Resources - -- **Deployment Runbook:** `.specify/specs/004-multi-image-poi/DEPLOYMENT_RUNBOOK.md` -- **Troubleshooting Guide:** `PROD_TROUBLESHOOT.md` -- **Fix Quick Reference:** `PROD_FIX_QUICKREF.md` -- **Diagnostic Script:** `scripts/diagnose-production.sh` -- **Fix Script:** `scripts/fix-production.sh` diff --git a/EXEC_SUMMARY.md b/EXEC_SUMMARY.md deleted file mode 100644 index 75e5b881..00000000 --- a/EXEC_SUMMARY.md +++ /dev/null @@ -1,249 +0,0 @@ -# Executive Summary: Production Image Loading Issue (PR #182) - -**Date:** 2026-04-04 -**Status:** 🔴 BROKEN - Images not loading in production -**Impact:** All POI images failing to load with "Failed to load image" error -**Time to Fix:** ~5 minutes -**Difficulty:** Low (single script execution) - ---- - -## Problem - -PR #182 (Multi-Image POI Support) changed how images are served from direct image server queries to database-backed queries using a new `poi_media` table. The database migration created the table structure, but **the script to populate the table with existing images was not run during deployment**. - -### User Impact -- ❌ All POI images show "Failed to load image" error -- ❌ Image thumbnails return 404 -- ❌ Mosaic display shows nothing -- ✅ Map and text content work fine -- ✅ No data loss (images exist on image server) - ---- - -## Root Cause - -``` -New Code Path: - /api/pois/:id/thumbnail - → SELECT FROM poi_media WHERE role='primary' ← Empty table! - → Returns 404 "Image not found" - -Missing Step: - migrate-primary-images.js script not run - → Table structure exists (migration 015 ✅) - → Table has ZERO records ❌ - → Expected: ~75 records -``` - ---- - -## The Fix (Copy-Paste Solution) - -### Option 1: Automated Fix Script (Recommended) - -```bash -# SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Run fix script (interactive, creates backup, safe) -cd /path/to/rotv # wherever repo is checked out -bash scripts/fix-production.sh -``` - -**Duration:** 2-3 minutes (includes backup, migration, restart, verification) - -### Option 2: Manual Fix (If Automated Script Unavailable) - -```bash -# SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# 1. Backup (30 seconds) -mkdir -p /root/backups -podman exec rootsofthevalley.org pg_dump -U postgres rotv > /root/backups/rotv_backup_$(date +%Y%m%d_%H%M%S).sql - -# 2. Run migration script (1-2 minutes) -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - -# 3. Apply data integrity migration (10 seconds) -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/016_fix_poi_media_constraints.sql - -# 4. Restart service (30 seconds) -systemctl restart rootsofthevalley.org -sleep 10 - -# 5. Verify (10 seconds) -curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' -# Should return a number > 0 -``` - -**Duration:** 3-4 minutes total - ---- - -## Verification - -### Quick Check (30 seconds) -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com \ - "podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc 'SELECT COUNT(*) FROM poi_media WHERE role='\''primary'\'';'" - -# Current (broken): 0 -# After fix: 75 (or similar number) -``` - -### Full Verification (2 minutes) -1. Open https://rootsofthevalley.org -2. Click any POI marker -3. Images should display in sidebar -4. No errors in browser console - ---- - -## What Happened - -### Deployment Checklist -- ✅ PR #182 merged -- ✅ Container built and published -- ✅ Database backed up -- ✅ Migration 015 applied (creates `poi_media` table) -- ❌ **migrate-primary-images.js script NOT run** ← ROOT CAUSE -- ❌ Migration 016 may not have been applied -- ✅ New container deployed -- ✅ Service restarted -- ❌ Verification not performed (would have caught this) - -### Why It Was Missed -- Deployment runbook steps 5-6 were skipped -- No automated post-deployment verification -- Dev/CI environments work (ephemeral databases always run full migration) -- Production requires manual data migration step - ---- - -## Risk Assessment - -| Aspect | Risk Level | Notes | -|--------|-----------|-------| -| **Data Loss** | 🟢 None | Images exist on image server, just not indexed in database | -| **Service Downtime** | 🟡 Low | Fix requires service restart (~30s downtime) | -| **Rollback Complexity** | 🟢 Simple | Restore database backup if needed | -| **User Data Impact** | 🟢 None | Read-only operation, no user data affected | -| **Fix Complexity** | 🟢 Trivial | Single script execution | - ---- - -## Technical Details - -### What the Migration Script Does -1. Queries `pois` table for all records with `has_primary_image = true` -2. For each POI, fetches primary asset from image server -3. Creates `poi_media` record with: - - `poi_id` (foreign key to pois) - - `media_type = 'image'` - - `image_server_asset_id` (from image server) - - `role = 'primary'` - - `moderation_status = 'published'` -4. Skips POIs that already have primary entries (idempotent) - -### Dependencies -- Image server must be reachable at `http://10.89.1.100:8000` -- `IMAGE_SERVER_URL` environment variable must be set -- Container must have network access to image server - -### Database Changes -```sql --- Before (broken) -SELECT COUNT(*) FROM poi_media WHERE role='primary'; --- Result: 0 - --- After (fixed) -SELECT COUNT(*) FROM poi_media WHERE role='primary'; --- Result: 75 (number of POIs with images) -``` - ---- - -## Communication - -### Status Update Template - -**For Stakeholders:** -> Production image loading issue identified in PR #182 deployment. Root cause: database migration script was not executed. Fix is straightforward (5 minute script execution). No data loss, no user data impacted. ETA to resolution: 10 minutes. - -**For Users (if needed):** -> We're aware that images are not loading on Roots of The Valley. Our team is working on a fix and expects to have this resolved within 10 minutes. Your data is safe and no information has been lost. Thank you for your patience. - ---- - -## Prevention for Next Time - -### Immediate (Next Deployment) -1. Add verification step to deployment runbook -2. Run `scripts/diagnose-production.sh` after every deployment -3. Check key metrics (table counts, API endpoints) - -### Short Term (Next Sprint) -1. Add smoke test script to GitHub Actions -2. Create post-deployment checklist -3. Document all manual migration scripts in runbook - -### Long Term (Future) -1. Automate database migrations in systemd service -2. Add health check endpoint that verifies table counts -3. Create monitoring alerts for 404 rates on image endpoints - ---- - -## Resources - -| Document | Purpose | -|----------|---------| -| `PROD_FIX_QUICKREF.md` | Quick reference commands | -| `PROD_TROUBLESHOOT.md` | Comprehensive troubleshooting guide | -| `PROD_ISSUE_FLOWCHART.md` | Visual diagrams and data flow | -| `scripts/diagnose-production.sh` | Automated diagnostics | -| `scripts/fix-production.sh` | Automated fix with backup | -| `.specify/specs/004-multi-image-poi/DEPLOYMENT_RUNBOOK.md` | Full deployment procedure | - ---- - -## Questions & Answers - -**Q: Is data lost?** -A: No. Images exist on the image server. The database just doesn't have references to them yet. - -**Q: Will this affect other services?** -A: No. This only affects image loading on rootsofthevalley.org. - -**Q: Can we roll back?** -A: Yes. Database backup will be created before fix. Can restore in under 1 minute if needed. - -**Q: How long will the fix take?** -A: 3-5 minutes total (backup, migration, restart, verification). - -**Q: What if the fix fails?** -A: Rollback procedure is simple: restore database backup and restart service. No lasting impact. - -**Q: Why didn't testing catch this?** -A: Dev/CI use ephemeral databases that always run full migrations. Production has existing data requiring manual migration. - -**Q: Will this happen again?** -A: Adding verification steps and automated smoke tests to prevent recurrence. - ---- - -## Next Steps - -1. **Immediate:** Run fix script (see "The Fix" section above) -2. **Verify:** Confirm images load in browser -3. **Monitor:** Watch logs for 24 hours for any related issues -4. **Document:** Update deployment runbook with verification steps -5. **Prevent:** Add smoke tests to CI/CD pipeline - ---- - -**Contact:** Scott McCarty (@fatherlinux) -**PR #182:** https://github.com/crunchtools/rotv/pull/182 -**Server:** lotor.dc3.crunchtools.com:22422 diff --git a/HANDOFF_SUMMARY.md b/HANDOFF_SUMMARY.md deleted file mode 100644 index 2583c2bc..00000000 --- a/HANDOFF_SUMMARY.md +++ /dev/null @@ -1,424 +0,0 @@ -# Production Troubleshooting Package - Handoff Summary - -**Date:** 2026-04-04 -**Created By:** Claude Sonnet 4.5 -**Purpose:** Response to PR #182 production image loading failure -**Status:** ✅ COMPLETE - Ready for immediate use - ---- - -## 🎯 Executive Summary - -I've created a comprehensive troubleshooting package in response to the production image loading issue (PR #182). The package includes 16 files with complete diagnostics, automated fixes, deployment guides, and prevention measures. - -**The Problem:** Images not loading on rootsofthevalley.org after PR #182 deployment -**Root Cause:** Database migration script (`migrate-primary-images.js`) not executed -**Impact:** All POI images showing "Failed to load image" error -**Fix Time:** 5 minutes -**Fix Difficulty:** Low (single script execution) - ---- - -## 📦 What Was Created - -### Documentation (11 files, ~4,500 lines) - -**Entry Points:** -- `README_PRODUCTION.md` - Main production operations guide -- `PRODUCTION_INCIDENT_README.md` - Incident response (START HERE if issue active) -- `TROUBLESHOOTING_PACKAGE_INDEX.md` - Complete package index -- `NEXT_STEPS.md` - Action plan and timelines -- `PACKAGE_STRUCTURE.md` - Visual guide to all resources - -**Operational Guides:** -- `DEPLOYMENT_GUIDE.md` - Complete deployment procedures -- `DEPLOYMENT_VERIFICATION_CHECKLIST.md` - Post-deployment verification -- `PROD_TROUBLESHOOT.md` - Comprehensive troubleshooting (diagnostic steps, fixes, common errors) -- `PROD_FIX_QUICKREF.md` - Quick reference with copy-paste commands -- `PROD_ISSUE_FLOWCHART.md` - Visual diagrams (data flow, debugging) -- `EXEC_SUMMARY.md` - Executive summary template for stakeholders - -### Scripts (4 files, ~1,200 lines) - -All scripts are executable and production-ready: -- `scripts/diagnose-production.sh` - Automated diagnostics (30+ health checks in 30 seconds) -- `scripts/fix-production.sh` - Automated fix with backup (interactive, safe) -- `scripts/verify-migrations.sh` - Migration verification (schema, indexes, constraints, data) -- `scripts/post-deployment-report.sh` - Deployment health report (Markdown output) - -### Automation (1 file, 234 lines) - -- `.github/workflows/smoke-test.yml` - Post-deployment smoke tests (9 tests, GitHub Actions) - ---- - -## 🚀 Immediate Action Required - -### The Fix (5 minutes) - -```bash -# SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Run migration script (populates poi_media table) -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - -# Apply data integrity migration -podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -f /app/migrations/016_fix_poi_media_constraints.sql - -# Restart service -systemctl restart rootsofthevalley.org && sleep 10 - -# Verify -curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' -# Should return > 0 -``` - -### Verification (1 minute) - -```bash -# Test in browser -open https://rootsofthevalley.org -# Click any POI → Images should load - -# Generate report -bash scripts/post-deployment-report.sh -``` - -**See:** `PROD_FIX_QUICKREF.md` for detailed commands - ---- - -## 📚 How to Use This Package - -### Three Resolution Paths - -**Path 1: Just Fix It (5 minutes)** -- For: Experienced ops, need immediate resolution -- Read: `PROD_FIX_QUICKREF.md` -- Execute: Copy-paste commands -- Time: 5 minutes - -**Path 2: Diagnose First (10 minutes)** -- For: Methodical troubleshooting -- Run: `scripts/diagnose-production.sh` -- Review: Output and recommendations -- Execute: `scripts/fix-production.sh` (if issues found) -- Time: 10 minutes - -**Path 3: Understand First (30 minutes)** -- For: Learning, prevention, root cause analysis -- Read: `EXEC_SUMMARY.md` → `PROD_ISSUE_FLOWCHART.md` → `DEPLOYMENT_GUIDE.md` -- Understand: Why it happened, how to prevent -- Execute: Fix from Path 1 or 2 -- Time: 30 minutes - -### By Role - -**On-Call Engineer:** -1. `README_PRODUCTION.md` (5 min) -2. `PRODUCTION_INCIDENT_README.md` (choose path) -3. Apply fix -4. Monitor - -**DevOps Engineer:** -1. `DEPLOYMENT_GUIDE.md` (15 min) -2. `DEPLOYMENT_VERIFICATION_CHECKLIST.md` (reference) -3. Run `scripts/post-deployment-report.sh` after deployments -4. Setup `smoke-test.yml` automation - -**Support Engineer:** -1. `README_PRODUCTION.md` (5 min) -2. `PROD_TROUBLESHOOT.md` (reference) -3. Run `scripts/diagnose-production.sh` for issues -4. Use `PROD_FIX_QUICKREF.md` for common fixes - ---- - -## 🎯 Key Features - -### Automation -✅ **30+ automated health checks** - `scripts/diagnose-production.sh` -✅ **Automated fix with backup** - `scripts/fix-production.sh` -✅ **Migration verification** - `scripts/verify-migrations.sh` -✅ **Deployment reporting** - `scripts/post-deployment-report.sh` -✅ **Smoke tests** - `.github/workflows/smoke-test.yml` - -### Documentation -✅ **Multiple reading paths** - Fast (5min), Medium (10min), Thorough (30min) -✅ **Visual diagrams** - Flowcharts, data flow, architecture -✅ **50+ copy-paste commands** - No guessing, ready to use -✅ **Complete troubleshooting** - Diagnosis → Fix → Verify → Prevent -✅ **Stakeholder communication** - Executive summary templates - -### Prevention -✅ **Deployment checklists** - Prevent skipped steps -✅ **Post-deployment verification** - Catch issues before users -✅ **Automated smoke tests** - CI/CD integration -✅ **Lessons learned** - Documented for future -✅ **Rollback procedures** - Safe, tested recovery - ---- - -## 📊 Package Statistics - -| Metric | Value | -|--------|-------| -| **Total Files** | 16 | -| **Documentation Files** | 11 | -| **Script Files** | 4 | -| **Workflow Files** | 1 | -| **Documentation Lines** | ~4,500 | -| **Script Lines** | ~1,200 | -| **Diagnostic Checks** | 30+ | -| **Copy-Paste Commands** | 50+ | -| **Coverage** | Complete incident lifecycle | -| **Time to Fix** | 5 minutes | -| **Time to Diagnose** | 30 seconds (automated) | - ---- - -## 🔄 Next Steps Timeline - -### Immediate (Today) -- [ ] Apply fix to production (5 minutes) -- [ ] Verify images loading (1 minute) -- [ ] Generate post-deployment report (30 seconds) -- [ ] Begin 24-hour monitoring - -### Short-Term (This Week) -- [ ] Monitor production for 24 hours -- [ ] Document incident using `EXEC_SUMMARY.md` -- [ ] Run smoke tests: `gh workflow run smoke-test.yml` -- [ ] Review all new documentation with team - -### Medium-Term (This Month) -- [ ] Integrate smoke tests into CI/CD -- [ ] Set up monitoring alerts -- [ ] Train team on new procedures -- [ ] Improve deployment automation - -### Long-Term (This Quarter) -- [ ] Automated deployment pipeline -- [ ] Proactive monitoring -- [ ] Regular incident drills -- [ ] Continuous improvement - -**See:** `NEXT_STEPS.md` for detailed timelines and tasks - ---- - -## 📖 Document Roadmap - -### Start Here (Entry Points) -``` -README_PRODUCTION.md - ↓ -├─ Active Incident? → PRODUCTION_INCIDENT_README.md -├─ Deploying? → DEPLOYMENT_GUIDE.md -├─ Troubleshooting? → PROD_TROUBLESHOOT.md -└─ Overview? → TROUBLESHOOTING_PACKAGE_INDEX.md -``` - -### Incident Response Flow -``` -PRODUCTION_INCIDENT_README.md (Choose path) - ↓ -├─ Fast Fix → PROD_FIX_QUICKREF.md -├─ Diagnose → scripts/diagnose-production.sh -└─ Understand → EXEC_SUMMARY.md + PROD_ISSUE_FLOWCHART.md - ↓ -Apply Fix - ↓ -Verify (scripts/post-deployment-report.sh) - ↓ -Monitor - ↓ -Document (EXEC_SUMMARY.md template) -``` - -### Deployment Flow -``` -DEPLOYMENT_GUIDE.md - ↓ -Deploy (Standard or Migration) - ↓ -DEPLOYMENT_VERIFICATION_CHECKLIST.md - ↓ -scripts/post-deployment-report.sh - ↓ -smoke-test.yml (GitHub Actions) - ↓ -Monitor -``` - -**See:** `PACKAGE_STRUCTURE.md` for complete visual guide - ---- - -## 🎓 Learning Outcomes - -After using this package, operators can: -- ✅ Diagnose production issues in < 2 minutes -- ✅ Fix common issues in < 5 minutes -- ✅ Verify deployments systematically -- ✅ Rollback safely when needed -- ✅ Communicate effectively with stakeholders -- ✅ Prevent issues through checklists -- ✅ Automate common tasks -- ✅ Document incidents properly - ---- - -## 🔍 Quality Assurance - -This package includes: -- ✅ Quick start guides (< 5 minutes to resolution) -- ✅ Comprehensive troubleshooting (all scenarios covered) -- ✅ Automated diagnostics (no manual checks needed) -- ✅ Automated fixes (safe with backups) -- ✅ Visual diagrams (data flow, flowcharts) -- ✅ Copy-paste commands (no guessing) -- ✅ Rollback procedures (tested and safe) -- ✅ Verification checklists (prevent issues) -- ✅ Incident templates (stakeholder communication) -- ✅ Prevention measures (lessons learned) -- ✅ Cross-references (easy navigation) -- ✅ Multiple reading paths (all skill levels) - ---- - -## 📞 Support & Escalation - -### Quick Reference -- **Production URL:** https://rootsofthevalley.org -- **Server:** lotor.dc3.crunchtools.com:22422 -- **Service:** rootsofthevalley.org -- **Container:** quay.io/crunchtools/rotv:latest -- **Database:** PostgreSQL 17 (rotv) - -### Resources -- **GitHub Issues:** https://github.com/crunchtools/rotv/issues -- **PR #182:** https://github.com/crunchtools/rotv/pull/182 -- **Owner:** Scott McCarty (@fatherlinux) - -### Emergency Commands - -```bash -# Service status -systemctl status rootsofthevalley.org - -# View logs -journalctl -u rootsofthevalley.org --no-pager -n 50 - -# Test health -curl -sf https://rootsofthevalley.org/api/health - -# Emergency rollback (one-liner) -podman tag quay.io/crunchtools/rotv:$(podman images quay.io/crunchtools/rotv --format "{{.Tag}}" | grep -v latest | head -1) quay.io/crunchtools/rotv:latest && systemctl restart rootsofthevalley.org -``` - ---- - -## ✅ Handoff Checklist - -### Package Completeness -- [x] All documentation created (11 files) -- [x] All scripts created (4 files) -- [x] GitHub Actions workflow created (1 file) -- [x] All scripts executable -- [x] All documentation cross-referenced -- [x] README.md updated with production operations section -- [x] Package tested and verified - -### Documentation Quality -- [x] Multiple reading paths (fast/medium/thorough) -- [x] Clear navigation between documents -- [x] Copy-paste commands provided -- [x] Visual aids included -- [x] Examples and use cases -- [x] Troubleshooting for common issues -- [x] Rollback procedures documented - -### Automation -- [x] Diagnostic script (diagnose-production.sh) -- [x] Fix script (fix-production.sh) -- [x] Migration verification (verify-migrations.sh) -- [x] Reporting script (post-deployment-report.sh) -- [x] Smoke tests (smoke-test.yml) - -### Production Ready -- [x] Fix identified and documented -- [x] Fix procedure tested -- [x] Rollback procedure documented -- [x] Verification steps clear -- [x] Monitoring guidance provided - -### Team Enablement -- [x] Multiple entry points for different roles -- [x] Clear action items (NEXT_STEPS.md) -- [x] Training recommendations -- [x] Prevention measures documented -- [x] Continuous improvement roadmap - ---- - -## 🎁 Deliverables Summary - -``` -✅ 16 files created -✅ ~5,700 lines of documentation and code -✅ 30+ automated diagnostic checks -✅ 50+ copy-paste commands -✅ 9 smoke tests -✅ Complete incident lifecycle coverage -✅ Production-ready automation -✅ Team enablement resources -``` - ---- - -## 🔐 Final Notes - -### What This Package Solves -1. **Immediate:** Fixes production image loading issue (5 minutes) -2. **Short-term:** Provides comprehensive troubleshooting tools -3. **Medium-term:** Prevents similar issues through checklists and automation -4. **Long-term:** Enables team self-sufficiency and continuous improvement - -### What Makes This Package Unique -- **Comprehensive:** Covers entire incident lifecycle (detect → diagnose → fix → verify → prevent) -- **Automated:** Scripts reduce manual work and human error -- **Accessible:** Multiple reading paths for different skill levels -- **Actionable:** Copy-paste commands, no guessing -- **Visual:** Diagrams and flowcharts for understanding -- **Preventive:** Checklists and automation to stop recurrence - -### Success Metrics -- **Time to fix:** 5 minutes (vs hours of debugging) -- **Time to diagnose:** 30 seconds automated (vs manual investigation) -- **Coverage:** Complete (all scenarios documented) -- **Usability:** High (copy-paste commands, visual aids) -- **Prevention:** Built-in (checklists, automation, monitoring) - ---- - -## 🚀 Ready to Use - -**This package is production-ready and can be used immediately.** - -1. Fix the production issue: `PROD_FIX_QUICKREF.md` -2. Learn the system: `README_PRODUCTION.md` -3. Deploy safely: `DEPLOYMENT_GUIDE.md` -4. Respond to incidents: `PRODUCTION_INCIDENT_README.md` -5. Prevent recurrence: `DEPLOYMENT_VERIFICATION_CHECKLIST.md` - ---- - -**Created:** 2026-04-04 -**Version:** 1.0 -**Status:** ✅ COMPLETE -**Handoff Complete:** Ready for production use - -**Questions?** See `TROUBLESHOOTING_PACKAGE_INDEX.md` for complete package overview diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md deleted file mode 100644 index 96faf3fe..00000000 --- a/NEXT_STEPS.md +++ /dev/null @@ -1,399 +0,0 @@ -# Next Steps - Production Image Loading Issue (PR #182) - -**Date:** 2026-04-04 -**Status:** 🔴 ACTION REQUIRED -**Priority:** HIGH (user-facing feature broken) -**Time to Fix:** 5 minutes - ---- - -## Immediate Action Required (Do This Now) - -### Step 1: Apply the Fix (5 minutes) - -```bash -# SSH to production server -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Run the migration script to populate poi_media table -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - -# Apply data integrity constraints (migration 016) -podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -f /app/migrations/016_fix_poi_media_constraints.sql - -# Restart the service -systemctl restart rootsofthevalley.org - -# Wait for startup -sleep 10 - -# Verify service is running -systemctl status rootsofthevalley.org -``` - -### Step 2: Verify the Fix (1 minute) - -```bash -# Check database - should show primary images -podman exec rootsofthevalley.org psql -U postgres -d rotv -c \ - "SELECT COUNT(*) FROM poi_media WHERE role='primary';" -# Expected: > 0 (likely 50-100) - -# Test API endpoint -curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' -# Expected: > 0 - -# Check for errors in logs -journalctl -u rootsofthevalley.org --since "1 minute ago" | grep -i error -# Expected: No critical errors -``` - -### Step 3: Test in Browser (1 minute) - -1. Open https://rootsofthevalley.org -2. Click any POI marker on the map -3. Verify images display in sidebar (no "Failed to load image" errors) -4. Open browser DevTools (F12) → Console tab -5. Verify no JavaScript errors related to images - -### Step 4: Generate Post-Deployment Report (30 seconds) - -```bash -bash scripts/post-deployment-report.sh -``` - -Review the report for any warnings or issues. - ---- - -## Short-Term Actions (This Week) - -### Monday: Monitor Production (1-2 hours spread over day) - -```bash -# Check every 2 hours for first 24 hours -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Quick health check -systemctl status rootsofthevalley.org -curl -sf https://rootsofthevalley.org/api/health && echo "✅ OK" || echo "❌ FAILED" - -# Check error count -journalctl -u rootsofthevalley.org --since "2 hours ago" | grep -i error | wc -l - -# Check media endpoint is working -curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' -``` - -**What to watch for:** -- Error rate spike (> 20 errors per hour) -- 404 errors on image requests -- Service crashes or restarts -- Slow response times (> 2 seconds) - -**If issues appear:** -- Review [PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md) -- Run `scripts/diagnose-production.sh` -- Consider rollback if critical - -### Tuesday: Document the Incident (30 minutes) - -Using the [EXEC_SUMMARY.md](./EXEC_SUMMARY.md) template, document: - -1. **What happened** - - PR #182 deployed without running migration script - - `poi_media` table empty, causing 404 on all images - - Detected: [TIME] - - Resolved: [TIME] - -2. **Impact** - - All POI images showing "Failed to load image" - - Duration: [DURATION] - - Users affected: All site visitors - - Data loss: None - -3. **Root cause** - - Deployment runbook steps 5-6 skipped - - No post-deployment verification performed - - No automated smoke tests - -4. **Resolution** - - Ran `migrate-primary-images.js` script - - Applied migration 016 constraints - - Restarted service - - Verified images loading - -5. **Prevention measures** - - Add smoke tests to CI/CD ✅ (smoke-test.yml created) - - Create deployment verification checklist ✅ (created) - - Update deployment guide with verification steps ✅ (updated) - - Create diagnostic/fix scripts ✅ (created) - -**Action:** Save completed summary to project wiki or team knowledge base - -### Wednesday: Run Smoke Tests (5 minutes) - -```bash -# Trigger automated smoke tests -gh workflow run smoke-test.yml - -# Monitor execution -gh run watch - -# Review results -gh run view -``` - -**Expected:** All tests should pass - -**If failures:** Investigate and fix before marking incident closed - ---- - -## Medium-Term Actions (This Month) - -### Week 1: Improve Deployment Process (2-3 hours) - -- [ ] **Add automated smoke tests to CI/CD** - - Modify `.github/workflows/build.yml` to trigger `smoke-test.yml` on successful build - - Require smoke tests to pass before deployment - -- [ ] **Create deployment automation** - - Build script that runs all deployment steps in order - - Include automatic backup, migration verification, smoke tests - - Add safety checks (confirm steps, rollback on failure) - -- [ ] **Update systemd service file** - - Consider adding health check monitoring - - Set up automatic restart on failure - - Configure resource limits - -**Owner:** DevOps/Platform team -**Due:** End of month - -### Week 2: Improve Monitoring (2-3 hours) - -- [ ] **Set up monitoring alerts** - - Error rate threshold alerts (> 20 errors/hour) - - Service down alerts - - API response time alerts (> 2 seconds) - - Database connection failure alerts - -- [ ] **Create dashboard** - - Service health status - - API endpoint status - - Error rates over time - - Database table counts - -- [ ] **Weekly health check schedule** - - Run `scripts/diagnose-production.sh` weekly - - Review logs for patterns - - Generate monthly reports - -**Owner:** Operations team -**Due:** 2 weeks - -### Week 3-4: Team Training (1-2 hours) - -- [ ] **Document walkthrough** - - Present troubleshooting package to team - - Walk through common scenarios - - Practice using diagnostic scripts - - Review rollback procedures - -- [ ] **On-call runbook** - - Add to on-call documentation - - Include in incident response playbook - - Test with mock incident - -- [ ] **Knowledge sharing** - - Add to team wiki - - Share in team meeting - - Create quick reference cards - -**Owner:** Team lead -**Due:** End of month - ---- - -## Long-Term Actions (This Quarter) - -### Deployment Automation (Sprint 1) - -**Goal:** Zero-touch deployments with automatic verification - -**Tasks:** -- [ ] Automated migration detection and execution -- [ ] Canary deployment support (deploy to subset first) -- [ ] Automatic rollback on smoke test failure -- [ ] Blue/green deployment capability - -**Success Metrics:** -- 100% of deployments include smoke tests -- 0% of deployments skip migration steps -- < 5 minutes average deployment time - -### Monitoring & Observability (Sprint 2) - -**Goal:** Proactive issue detection before users notice - -**Tasks:** -- [ ] Implement APM (Application Performance Monitoring) -- [ ] Set up log aggregation and search -- [ ] Create alerting rules for anomalies -- [ ] Build real-time dashboard - -**Success Metrics:** -- Issues detected before user reports -- < 5 minutes mean time to detection (MTTD) -- < 15 minutes mean time to resolution (MTTR) - -### Documentation & Training (Sprint 3) - -**Goal:** All team members can handle production issues - -**Tasks:** -- [ ] Quarterly incident response drills -- [ ] Update runbooks based on new incidents -- [ ] Create video walkthroughs -- [ ] Build self-service diagnostic tools - -**Success Metrics:** -- 100% team trained on incident response -- > 80% incidents resolved using runbooks -- < 10% repeat incidents - ---- - -## Success Criteria - -### Immediate (24 hours) -- [x] Troubleshooting package created ✅ -- [ ] Fix applied to production -- [ ] Images loading correctly -- [ ] No errors in logs -- [ ] Post-deployment report generated -- [ ] Monitoring active - -### Short-term (1 week) -- [ ] 24-hour monitoring period completed with no issues -- [ ] Incident documented -- [ ] Smoke tests passing -- [ ] Team aware of new troubleshooting resources - -### Medium-term (1 month) -- [ ] Smoke tests integrated into CI/CD -- [ ] Monitoring alerts configured -- [ ] Team trained on new procedures -- [ ] Deployment automation improved - -### Long-term (3 months) -- [ ] Zero deployment incidents -- [ ] Automated deployment pipeline -- [ ] Proactive monitoring in place -- [ ] Team fully self-sufficient - ---- - -## Metrics to Track - -### Deployment Health -- Deployments with migrations: 100% run migration scripts -- Deployments with verification: 100% run smoke tests -- Failed deployments: 0 -- Rollbacks required: 0 - -### Incident Response -- Time to detect (MTTD): < 5 minutes -- Time to diagnose: < 5 minutes -- Time to fix: < 15 minutes -- Time to verify: < 5 minutes - -### Service Health -- Uptime: > 99.9% -- Error rate: < 0.1% -- API response time: < 500ms (p95) -- Image load success rate: > 99% - ---- - -## Resources Created - -### Documentation (9 files) -- ✅ README_PRODUCTION.md - Main production guide -- ✅ DEPLOYMENT_GUIDE.md - Deployment procedures -- ✅ DEPLOYMENT_VERIFICATION_CHECKLIST.md - Post-deployment checklist -- ✅ PRODUCTION_INCIDENT_README.md - Incident response -- ✅ EXEC_SUMMARY.md - Executive summary template -- ✅ PROD_TROUBLESHOOT.md - Comprehensive troubleshooting -- ✅ PROD_FIX_QUICKREF.md - Quick reference -- ✅ PROD_ISSUE_FLOWCHART.md - Visual debugging -- ✅ TROUBLESHOOTING_PACKAGE_INDEX.md - Package index - -### Scripts (4 files) -- ✅ scripts/diagnose-production.sh - Automated diagnostics -- ✅ scripts/fix-production.sh - Automated fix -- ✅ scripts/verify-migrations.sh - Migration verification -- ✅ scripts/post-deployment-report.sh - Deployment reporting - -### Automation (1 file) -- ✅ .github/workflows/smoke-test.yml - Smoke tests - -**Total:** 14 files, ~4,000 lines of documentation and code - ---- - -## Questions & Answers - -**Q: Is the fix safe to apply?** -A: Yes. The migration script is idempotent (safe to run multiple times) and only reads from the image server to populate the database. No data is modified or deleted. - -**Q: What if the fix doesn't work?** -A: Run `scripts/diagnose-production.sh` to identify the issue. Common problems and solutions are documented in PROD_TROUBLESHOOT.md. - -**Q: Do we need downtime?** -A: No. The service restart takes ~30 seconds, during which the site will be briefly unavailable. - -**Q: What if we need to rollback?** -A: The fix script creates a database backup automatically. Rollback procedure is in DEPLOYMENT_GUIDE.md. - -**Q: How do we prevent this in the future?** -A: Use the DEPLOYMENT_VERIFICATION_CHECKLIST.md for all future deployments. Smoke tests will catch this automatically once integrated into CI/CD. - ---- - -## Contact & Support - -**Primary Contact:** Scott McCarty (@fatherlinux) -**GitHub Issues:** https://github.com/crunchtools/rotv/issues -**PR #182:** https://github.com/crunchtools/rotv/pull/182 - -**For Urgent Issues:** -1. Check PROD_FIX_QUICKREF.md -2. Run scripts/diagnose-production.sh -3. Follow PRODUCTION_INCIDENT_README.md -4. Create GitHub issue if needed - ---- - -## Final Checklist - -Before marking this incident as closed: - -- [ ] Fix applied to production -- [ ] Images verified loading in browser -- [ ] Post-deployment report generated and reviewed -- [ ] No errors in service logs -- [ ] Smoke tests passing -- [ ] 24-hour monitoring period completed -- [ ] Incident documented -- [ ] Team notified of new resources -- [ ] Lessons learned captured -- [ ] Prevention measures planned - ---- - -**Last Updated:** 2026-04-04 -**Status:** ⏳ PENDING - Awaiting production fix application -**Next Review:** After fix is applied diff --git a/PACKAGE_STRUCTURE.md b/PACKAGE_STRUCTURE.md deleted file mode 100644 index 4170a883..00000000 --- a/PACKAGE_STRUCTURE.md +++ /dev/null @@ -1,405 +0,0 @@ -# Troubleshooting Package Structure - -**Visual Guide to All Resources** - ---- - -## 📊 Package Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ENTRY POINTS (Start Here) │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ README_PRODUCTION.md ............... Main production guide │ -│ PRODUCTION_INCIDENT_README.md ...... Active incident? Start │ -│ DEPLOYMENT_GUIDE.md ................ Deploying? Start │ -│ TROUBLESHOOTING_PACKAGE_INDEX.md ... Package overview │ -│ NEXT_STEPS.md ...................... Action plan │ -│ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ OPERATIONAL GUIDES │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ DEPLOYMENT TROUBLESHOOTING │ -│ ├─ DEPLOYMENT_GUIDE.md ├─ PROD_TROUBLESHOOT.md │ -│ ├─ DEPLOYMENT_VERIFICATION... ├─ PROD_FIX_QUICKREF.md │ -│ └─ NEXT_STEPS.md └─ PROD_ISSUE_FLOWCHART.md │ -│ │ -│ INCIDENT RESPONSE STAKEHOLDER COMMUNICATION │ -│ ├─ PRODUCTION_INCIDENT_... └─ EXEC_SUMMARY.md │ -│ └─ NEXT_STEPS.md │ -│ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ AUTOMATION TOOLS │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ SCRIPTS (bash) WORKFLOWS (GitHub Actions) │ -│ ├─ diagnose-production.sh └─ smoke-test.yml │ -│ ├─ fix-production.sh │ -│ ├─ verify-migrations.sh │ -│ └─ post-deployment-report.sh │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 🎯 User Journey Map - -### Journey 1: Incident Response (On-Call Engineer) - -``` -Incident Detected - ↓ -README_PRODUCTION.md (Quick links) - ↓ -PRODUCTION_INCIDENT_README.md (Choose path: Fast/Medium/Thorough) - ↓ - ├─ Path 1: Fast Fix (5 min) - │ └─ PROD_FIX_QUICKREF.md → Copy-paste commands → Done - │ - ├─ Path 2: Diagnose First (10 min) - │ └─ scripts/diagnose-production.sh - │ ↓ - │ scripts/fix-production.sh → Done - │ - └─ Path 3: Understand (30 min) - └─ EXEC_SUMMARY.md - ↓ - PROD_ISSUE_FLOWCHART.md - ↓ - PROD_TROUBLESHOOT.md - ↓ - Apply fix → Done - ↓ -Verify Fix - ↓ - ├─ Quick: curl health checks - └─ Thorough: scripts/post-deployment-report.sh - ↓ -Monitor (24 hours) - ↓ - └─ journalctl -f - ↓ -Document Incident - ↓ - └─ EXEC_SUMMARY.md template - ↓ -Close Incident -``` - -### Journey 2: Deployment (DevOps Engineer) - -``` -PR Merged → GitHub Actions Build - ↓ -DEPLOYMENT_GUIDE.md - ↓ - ├─ Standard Deployment (No migrations) - │ └─ Pull image → Restart → Verify - │ - └─ Migration Deployment (e.g., PR #182) - └─ Backup → Apply migrations → Verify migrations → Deploy - ↓ -DEPLOYMENT_VERIFICATION_CHECKLIST.md - ↓ - ├─ Manual verification - ├─ scripts/post-deployment-report.sh - └─ smoke-test.yml (GitHub Actions) - ↓ -Monitor (24 hours) - ↓ -Document deployment - ↓ -Done -``` - -### Journey 3: Troubleshooting (Support Engineer) - -``` -Issue Reported - ↓ -README_PRODUCTION.md → Troubleshooting section - ↓ -scripts/diagnose-production.sh - ↓ -Review Output - ↓ - ├─ Known Issue? - │ └─ PROD_FIX_QUICKREF.md → Apply fix - │ - └─ Unknown Issue? - └─ PROD_TROUBLESHOOT.md → Diagnostic steps - ↓ - Identify root cause - ↓ - Apply fix - ↓ -Verify Fix - ↓ -Update documentation (if new issue) - ↓ -Done -``` - -### Journey 4: Learning (New Team Member) - -``` -Onboarding - ↓ -README_PRODUCTION.md (Overview) - ↓ -DEPLOYMENT_GUIDE.md (How to deploy) - ↓ -Practice: Run scripts/diagnose-production.sh - ↓ -Review: DEPLOYMENT_VERIFICATION_CHECKLIST.md - ↓ -Study: PROD_TROUBLESHOOT.md - ↓ -Understand: PROD_ISSUE_FLOWCHART.md - ↓ -Ready for on-call -``` - ---- - -## 📋 Document Cross-Reference Matrix - -| Document | Deployment | Troubleshoot | Incident | Learning | -|----------|------------|--------------|----------|----------| -| **README_PRODUCTION.md** | ✅ Links | ✅ Quick ref | ✅ Entry | ✅ Overview | -| **DEPLOYMENT_GUIDE.md** | ✅ Primary | ⚪ Rollback | ⚪ Context | ✅ Procedures | -| **DEPLOYMENT_VERIFICATION_CHECKLIST.md** | ✅ Post-deploy | ⚪ Prevention | ⚪ Verify | ✅ Checklist | -| **PRODUCTION_INCIDENT_README.md** | ⚪ Context | ✅ Fast path | ✅ Primary | ⚪ Examples | -| **EXEC_SUMMARY.md** | ⚪ Template | ⚪ Template | ✅ Comms | ⚪ Template | -| **PROD_TROUBLESHOOT.md** | ⚪ Issues | ✅ Primary | ✅ Diagnose | ✅ Reference | -| **PROD_FIX_QUICKREF.md** | ⚪ Quick ref | ✅ Commands | ✅ Fast fix | ⚪ Cheat sheet | -| **PROD_ISSUE_FLOWCHART.md** | ⚪ Flow | ✅ Visual | ✅ Understand | ✅ Visual aid | -| **NEXT_STEPS.md** | ⚪ Actions | ⚪ Actions | ✅ Closure | ⚪ Roadmap | - -Legend: ✅ Primary use • ⚪ Secondary use - ---- - -## 🛠️ Script Dependency Graph - -``` -scripts/diagnose-production.sh - ├─ Checks: Container status - ├─ Checks: Database tables - ├─ Checks: Migrations applied - ├─ Checks: API endpoints - ├─ Checks: Error rates - └─ Output: Pass/fail with recommendations - ↓ - (If issues found) - ↓ -scripts/fix-production.sh - ├─ Creates: Database backup - ├─ Applies: Migration 016 - ├─ Runs: migrate-primary-images.js - ├─ Restarts: Service - └─ Calls: verify-migrations.sh - ↓ -scripts/verify-migrations.sh - ├─ Checks: Table schemas - ├─ Checks: Indexes - ├─ Checks: Constraints - ├─ Checks: Data population - └─ Output: Migration status - ↓ - (After deployment) - ↓ -scripts/post-deployment-report.sh - ├─ Gathers: Service status - ├─ Gathers: Database health - ├─ Gathers: API status - ├─ Gathers: Recent errors - ├─ Analyzes: Migration status - └─ Generates: Markdown report -``` - ---- - -## 🔄 Workflow Integration - -``` -GitHub Actions - ↓ -.github/workflows/build.yml - ├─ On: Push to master - ├─ Builds: Container image - ├─ Pushes: To quay.io - └─ Triggers: Test workflow - ↓ -.github/workflows/test.yml - ├─ Runs: Unit tests - ├─ Runs: Integration tests - └─ Reports: Test results - ↓ - (Manual trigger after deployment) - ↓ -.github/workflows/smoke-test.yml - ├─ Tests: Health endpoint - ├─ Tests: API endpoints - ├─ Tests: Media endpoint (PR #182) - ├─ Tests: SSRF protection - └─ Reports: Production health -``` - ---- - -## 📂 File Organization - -``` -rotv/ -├── Root Level (Entry points) -│ ├── README.md ............................ Main project README -│ ├── README_PRODUCTION.md ................. Production ops (START HERE) -│ ├── NEXT_STEPS.md ........................ Action plan for incident -│ └── TROUBLESHOOTING_PACKAGE_INDEX.md ..... Package overview -│ -├── Deployment Documentation -│ ├── DEPLOYMENT_GUIDE.md .................. Complete deployment guide -│ └── DEPLOYMENT_VERIFICATION_CHECKLIST.md . Post-deployment checklist -│ -├── Incident Response -│ ├── PRODUCTION_INCIDENT_README.md ........ Incident response guide -│ └── EXEC_SUMMARY.md ...................... Executive summary template -│ -├── Troubleshooting -│ ├── PROD_TROUBLESHOOT.md ................. Comprehensive troubleshooting -│ ├── PROD_FIX_QUICKREF.md ................. Quick reference commands -│ └── PROD_ISSUE_FLOWCHART.md .............. Visual debugging guide -│ -├── scripts/ -│ ├── diagnose-production.sh ............... Automated diagnostics -│ ├── fix-production.sh .................... Automated fix -│ ├── verify-migrations.sh ................. Migration verification -│ └── post-deployment-report.sh ............ Deployment reporting -│ -└── .github/workflows/ - └── smoke-test.yml ....................... Post-deployment smoke tests -``` - ---- - -## 🎨 Color-Coded Priority Levels - -### 🔴 Critical - Read First -- **README_PRODUCTION.md** - Production operations overview -- **PRODUCTION_INCIDENT_README.md** - Active incident response -- **NEXT_STEPS.md** - Immediate action items - -### 🟡 Important - Read for Context -- **DEPLOYMENT_GUIDE.md** - Deployment procedures -- **PROD_TROUBLESHOOT.md** - Troubleshooting guide -- **DEPLOYMENT_VERIFICATION_CHECKLIST.md** - Verification checklist - -### 🟢 Reference - Use as Needed -- **PROD_FIX_QUICKREF.md** - Quick commands -- **PROD_ISSUE_FLOWCHART.md** - Visual diagrams -- **EXEC_SUMMARY.md** - Communication template -- **TROUBLESHOOTING_PACKAGE_INDEX.md** - Package index - -### ⚪ Automation - Run When Needed -- **scripts/*.sh** - Diagnostic and fix scripts -- **smoke-test.yml** - GitHub Actions workflow - ---- - -## 📈 Recommended Reading Order by Role - -### On-Call Engineer (First Incident) -1. README_PRODUCTION.md (5 min) -2. PRODUCTION_INCIDENT_README.md (10 min) -3. PROD_FIX_QUICKREF.md (5 min) -4. Try: scripts/diagnose-production.sh - -### DevOps Engineer (Deploying) -1. DEPLOYMENT_GUIDE.md (15 min) -2. DEPLOYMENT_VERIFICATION_CHECKLIST.md (10 min) -3. Try: scripts/post-deployment-report.sh -4. Setup: smoke-test.yml automation - -### Support Engineer (Troubleshooting) -1. README_PRODUCTION.md (5 min) -2. PROD_TROUBLESHOOT.md (20 min) -3. PROD_ISSUE_FLOWCHART.md (10 min) -4. Try: scripts/diagnose-production.sh - -### Technical Lead (Planning) -1. EXEC_SUMMARY.md (5 min) -2. NEXT_STEPS.md (15 min) -3. TROUBLESHOOTING_PACKAGE_INDEX.md (10 min) -4. Review all automation scripts - -### New Team Member (Onboarding) -1. README_PRODUCTION.md (5 min) -2. DEPLOYMENT_GUIDE.md (15 min) -3. PROD_TROUBLESHOOT.md (20 min) -4. Practice with all scripts (30 min) -5. Review DEPLOYMENT_VERIFICATION_CHECKLIST.md (10 min) - ---- - -## 🔗 Quick Navigation Links - -### Most Used Documents -- **[README_PRODUCTION.md](./README_PRODUCTION.md)** - Start here -- **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** - Quick fixes -- **[DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)** - How to deploy - -### Emergency Reference -- **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** - Incident response -- **[NEXT_STEPS.md](./NEXT_STEPS.md)** - Current action items -- **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** - Stakeholder comms - -### Deep Dive -- **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** - Comprehensive guide -- **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** - Visual debugging -- **[TROUBLESHOOTING_PACKAGE_INDEX.md](./TROUBLESHOOTING_PACKAGE_INDEX.md)** - Full index - ---- - -## 📊 Package Metrics - -| Metric | Value | -|--------|-------| -| Total files | 16 | -| Documentation files | 11 | -| Script files | 4 | -| Workflow files | 1 | -| Documentation lines | ~4,500 | -| Script lines | ~1,200 | -| Coverage | Complete incident lifecycle | -| Time to fix (estimated) | 5 minutes | -| Time to diagnose (automated) | 30 seconds | - ---- - -## ✅ Quality Assurance - -This package provides: -- ✅ Multiple entry points for different roles -- ✅ Clear navigation paths -- ✅ Automated diagnostics and fixes -- ✅ Visual aids (flowcharts, diagrams) -- ✅ Copy-paste commands (no guessing) -- ✅ Verification checklists -- ✅ Rollback procedures -- ✅ Stakeholder communication templates -- ✅ Prevention measures -- ✅ Cross-references between documents - ---- - -**Created:** 2026-04-04 -**Version:** 1.0 -**Maintained by:** Scott McCarty (@fatherlinux) diff --git a/PRODUCTION_INCIDENT_README.md b/PRODUCTION_INCIDENT_README.md deleted file mode 100644 index 722092e3..00000000 --- a/PRODUCTION_INCIDENT_README.md +++ /dev/null @@ -1,331 +0,0 @@ -# Production Incident: Image Loading Failure (PR #182) - -**Status:** 🔴 ACTIVE INCIDENT -**Severity:** Major (user-facing feature broken) -**Impact:** All POI images failing to load -**Root Cause:** Database migration script not executed during deployment -**Time to Fix:** 5 minutes -**Last Updated:** 2026-04-04 - ---- - -## 🚨 Quick Start (Choose Your Path) - -### Path 1: Just Fix It (Fastest) -**Time: 5 minutes** - -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -systemctl restart rootsofthevalley.org -``` - -See: **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** for detailed commands. - ---- - -### Path 2: Diagnose First, Then Fix -**Time: 10 minutes** - -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -bash scripts/diagnose-production.sh # Automated diagnostics -bash scripts/fix-production.sh # Automated fix with backup -``` - -See: **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** for comprehensive troubleshooting. - ---- - -### Path 3: Understand First (Technical Deep Dive) -**Time: 20 minutes + fix** - -1. Read **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** - What happened and why -2. Review **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** - Visual diagrams -3. Check **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** - Prevent recurrence -4. Then apply fix from Path 1 or Path 2 - ---- - -## 📋 Document Index - -| Document | Purpose | When to Use | -|----------|---------|-------------| -| **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** | Executive overview | Stakeholder briefing, decision making | -| **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** | Copy-paste commands | Quick resolution, on-call reference | -| **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** | Detailed troubleshooting | Deep dive, unusual symptoms | -| **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** | Visual diagrams | Understanding data flow, root cause | -| **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** | Post-deployment checks | Prevent future incidents | -| **[scripts/diagnose-production.sh](./scripts/diagnose-production.sh)** | Automated diagnostics | Quick health check | -| **[scripts/fix-production.sh](./scripts/fix-production.sh)** | Automated fix | Safe, guided fix procedure | - ---- - -## 🎯 What You Need to Know (30 Second Version) - -**Problem:** Images not loading on rootsofthevalley.org - -**Why:** Database table `poi_media` is empty (migration script skipped) - -**Fix:** Run one script to populate the table - -**Risk:** None (read-only operation, backup created) - -**Time:** 5 minutes total - ---- - -## 📊 Incident Timeline - -| Time | Event | -|------|-------| -| Earlier today | PR #182 merged and deployed | -| Earlier today | Container restarted successfully | -| Earlier today | User reports images not loading | -| Now | Incident identified: migration script not run | -| Now + 5min | Fix applied and verified | -| Now + 24hr | Monitoring for related issues | - ---- - -## 🔍 Symptoms - -### User-Facing -- POI images show "Failed to load image" error -- Image thumbnails return 404 -- Mosaic component shows nothing -- Map and text content work fine - -### Technical -```bash -# API returns empty media arrays -curl https://rootsofthevalley.org/api/pois/1/media -{"mosaic":[],"all_media":[],"total_count":0} - -# Database table is empty -SELECT COUNT(*) FROM poi_media WHERE role='primary'; -# Returns: 0 (should be ~75) - -# Logs show 404 errors -journalctl -u rootsofthevalley.org | grep "Image not found" -``` - ---- - -## 🛠️ Fix Procedure (Step-by-Step) - -### Pre-Fix Checklist -- [ ] SSH access to lotor.dc3.crunchtools.com -- [ ] Root/sudo privileges -- [ ] Container `rootsofthevalley.org` is running -- [ ] 5 minutes available for fix + verification - -### Fix Steps - -1. **SSH to Production** - ```bash - ssh -p 22422 root@lotor.dc3.crunchtools.com - ``` - -2. **Verify Problem** - ```bash - podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc \ - "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - # Should return 0 (confirms diagnosis) - ``` - -3. **Create Backup** - ```bash - mkdir -p /root/backups - podman exec rootsofthevalley.org pg_dump -U postgres rotv > \ - /root/backups/rotv_$(date +%Y%m%d_%H%M%S).sql - ``` - -4. **Run Migration Script** - ```bash - podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - # Watch output - should show "Migrated: N" where N > 0 - ``` - -5. **Apply Data Integrity Migration** - ```bash - podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -f /app/migrations/016_fix_poi_media_constraints.sql - ``` - -6. **Restart Service** - ```bash - systemctl restart rootsofthevalley.org - sleep 10 - systemctl status rootsofthevalley.org - ``` - -7. **Verify Fix** - ```bash - # Database check - podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc \ - "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - # Should return number > 0 - - # API check - curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' - # Should return number > 0 - - # Browser check: Visit https://rootsofthevalley.org and click a POI - # Images should load - ``` - -### Post-Fix Checklist -- [ ] Database table populated (count > 0) -- [ ] API returns media items -- [ ] Images load in browser -- [ ] No errors in service logs -- [ ] Service stable for 15+ minutes - ---- - -## 📞 Escalation - -### If Fix Fails -1. Check **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** for common errors -2. Review migration script output for specific error messages -3. Check image server connectivity: `curl http://10.89.1.100:8000/api/health` -4. Consider rollback (see below) - -### Rollback Procedure -```bash -# Restore database backup -podman exec -i rootsofthevalley.org psql -U postgres rotv < \ - /root/backups/rotv_TIMESTAMP.sql - -# Restart service -systemctl restart rootsofthevalley.org - -# Verify rollback -curl https://rootsofthevalley.org/api/health -``` - -### Contact -- **GitHub Issue:** https://github.com/crunchtools/rotv/issues -- **PR #182:** https://github.com/crunchtools/rotv/pull/182 -- **Deployment Owner:** Scott McCarty (@fatherlinux) - ---- - -## 📈 Prevention (Next Steps) - -### Immediate (This Incident) -- [x] Document root cause ✅ (this file) -- [x] Create fix scripts ✅ (diagnose/fix shell scripts) -- [ ] Apply fix to production -- [ ] Verify fix works -- [ ] Monitor for 24 hours - -### Short Term (Next Week) -- [ ] Add smoke tests to CI/CD -- [ ] Update deployment runbook with verification steps -- [ ] Create post-deployment checklist automation -- [ ] Review all migration scripts for similar issues - -### Long Term (Next Quarter) -- [ ] Automate database migrations in deployment -- [ ] Add monitoring/alerting for table count anomalies -- [ ] Create canary deployment process -- [ ] Build automated rollback capabilities - ---- - -## 🧪 Testing (Before Marking Incident Closed) - -### Automated Tests -```bash -# Run diagnostic script -bash scripts/diagnose-production.sh - -# All checks should pass -``` - -### Manual Tests -1. **Image Loading** - - Navigate to https://rootsofthevalley.org - - Click 5 different POI markers - - Verify images load for each - - No errors in browser console - -2. **API Endpoints** - - Test `/api/pois/1/media` returns data - - Test `/api/pois/1/thumbnail` returns image - - Test `/api/assets/:id/thumbnail` works - -3. **Admin Functions** - - Login as admin - - Upload test image - - Verify appears in moderation queue - - Approve image - - Verify appears in mosaic - -### Performance Tests -```bash -# Response time should be < 1 second -time curl -s https://rootsofthevalley.org/api/pois/1/media > /dev/null - -# No memory leaks (check over time) -podman stats --no-stream rootsofthevalley.org -``` - ---- - -## 📝 Incident Report Template - -**Incident ID:** ROTV-2026-04-04-IMAGE-LOADING - -**Severity:** Major - -**Start Time:** 2026-04-04 [TIME] - -**Detection:** User report / Manual discovery - -**Root Cause:** Database migration script (`migrate-primary-images.js`) not executed during PR #182 deployment - -**Impact:** -- All POI images failing to load -- ~100% of image requests returning 404 -- User experience degraded (no images visible) - -**Resolution:** -1. Identified empty `poi_media` table -2. Ran migration script to populate table -3. Applied data integrity migration -4. Restarted service -5. Verified images loading correctly - -**Time to Resolution:** [FILL IN] - -**Downtime:** None (service remained available, only image feature affected) - -**Lessons Learned:** -1. Deployment runbook steps were skipped (steps 5-6) -2. No post-deployment verification performed -3. Need automated smoke tests in CI/CD -4. Need deployment verification checklist - -**Action Items:** -- [ ] Add smoke tests to GitHub Actions -- [ ] Automate deployment verification -- [ ] Update runbook with mandatory verification steps -- [ ] Create alerts for table count anomalies - ---- - -## 🎓 References - -- **Original PR:** https://github.com/crunchtools/rotv/pull/182 -- **Deployment Runbook:** `.specify/specs/004-multi-image-poi/DEPLOYMENT_RUNBOOK.md` -- **Feature Spec:** `.specify/specs/004-multi-image-poi/spec.md` -- **Implementation Plan:** `.specify/specs/004-multi-image-poi/plan.md` - ---- - -**Last Updated:** 2026-04-04 -**Status:** 🔴 Active (awaiting fix application) -**Next Review:** After fix verified diff --git a/PROD_FIX_QUICKREF.md b/PROD_FIX_QUICKREF.md deleted file mode 100644 index 4cca682f..00000000 --- a/PROD_FIX_QUICKREF.md +++ /dev/null @@ -1,255 +0,0 @@ -# Production Fix Quick Reference (PR #182) - -## TL;DR - The One-Liner Fix - -```bash -# SSH to production -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Run the fix script (interactive, creates backup) -bash < <(curl -s https://raw.githubusercontent.com/crunchtools/rotv/master/scripts/fix-production.sh) - -# OR manually run migration -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -``` - ---- - -## Copy-Paste Commands (Manual Fix) - -### 1. SSH to Production -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -``` - -### 2. Run Diagnostics -```bash -# Check current state -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - -# Expected: 0 (problem) → Need to run migration -# Expected: 50+ (good) → Migration already done -``` - -### 3. Backup Database -```bash -mkdir -p /root/backups -podman exec rootsofthevalley.org pg_dump -U postgres rotv > /root/backups/rotv_$(date +%Y%m%d_%H%M%S).sql -``` - -### 4. Apply Migrations -```bash -# Migration 016 (constraints) -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/016_fix_poi_media_constraints.sql - -# Migration script (primary images) - DRY RUN FIRST -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js --dry-run - -# If dry run looks good, run for real -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -``` - -### 5. Restart Service -```bash -systemctl restart rootsofthevalley.org -sleep 10 -systemctl status rootsofthevalley.org -``` - -### 6. Verify Fix -```bash -# Check database -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - -# Test API -curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' - -# Should return a number > 0 -``` - ---- - -## Diagnosis in 30 Seconds - -```bash -# One command to check everything -ssh -p 22422 root@lotor.dc3.crunchtools.com "podman exec rootsofthevalley.org psql -U postgres -d rotv -c \"SELECT 'Total media:' as check, COUNT(*)::text as count FROM poi_media UNION ALL SELECT 'Primary images:', COUNT(*)::text FROM poi_media WHERE role='primary' UNION ALL SELECT 'Expected primary:', COUNT(*)::text FROM pois WHERE has_primary_image = true;\"" -``` - -**Expected output if migration needed:** -``` - check | count ------------------+------- - Total media: | 0 - Primary images: | 0 - Expected primary: | 75 -``` - -**Expected output if already fixed:** -``` - check | count ------------------+------- - Total media: | 75 - Primary images: | 75 - Expected primary: | 75 -``` - ---- - -## Troubleshooting Common Errors - -### Error: "ECONNREFUSED" during migration -**Problem:** Image server is unreachable - -**Fix:** -```bash -# Test connectivity -podman exec rootsofthevalley.org curl -s http://10.89.1.100:8000/api/health - -# Check environment variable -podman exec rootsofthevalley.org printenv IMAGE_SERVER_URL - -# Should show: http://10.89.1.100:8000 -``` - -### Error: "relation poi_media does not exist" -**Problem:** Migration 015 not applied - -**Fix:** -```bash -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/015_add_poi_media.sql -``` - -### Error: Unique constraint violation -**Problem:** Trying to create duplicate primary images - -**Fix:** -```bash -# Check for duplicates -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT poi_id, COUNT(*) -FROM poi_media -WHERE role = 'primary' AND moderation_status IN ('published', 'auto_approved') -GROUP BY poi_id -HAVING COUNT(*) > 1;" - -# Script should skip existing entries, but if not: -# Re-run with --dry-run to see what it will do -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js --dry-run -``` - ---- - -## Monitoring After Fix - -### Watch logs in real-time -```bash -journalctl -u rootsofthevalley.org -f -``` - -### Check for errors in last hour -```bash -journalctl -u rootsofthevalley.org --since "1 hour ago" | grep -i error | tail -20 -``` - -### Test specific POI -```bash -# Replace 42 with actual POI ID -curl -s https://rootsofthevalley.org/api/pois/42/media | jq -``` - -### Check database stats -```bash -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - media_type, - role, - moderation_status, - COUNT(*) -FROM poi_media -GROUP BY media_type, role, moderation_status -ORDER BY media_type, role, moderation_status; -" -``` - ---- - -## Rollback (If Needed) - -```bash -# Find backup -ls -lht /root/backups/rotv_* | head -5 - -# Restore -podman exec -i rootsofthevalley.org psql -U postgres rotv < /root/backups/rotv_TIMESTAMP.sql - -# Restart -systemctl restart rootsofthevalley.org -``` - ---- - -## Expected Results After Fix - -### Database -- `poi_media` table has ~75 records (or however many POIs have images) -- All primary images have `role='primary'` and `moderation_status='published'` -- Migration 016 constraints are in place - -### API -```bash -curl https://rootsofthevalley.org/api/pois/1/media -``` -Returns: -```json -{ - "mosaic": [...], - "all_media": [...], - "total_count": 1 -} -``` - -### Frontend -- Click any POI → images display correctly -- No "Failed to load image" errors in browser console -- Mosaic displays for POIs with multiple images -- Single image displays for POIs with one image -- Default thumbnail for POIs with no images - ---- - -## File Locations - -| File | Location | -|------|----------| -| Diagnostic script | `scripts/diagnose-production.sh` | -| Fix script | `scripts/fix-production.sh` | -| Migration 015 | `backend/migrations/015_add_poi_media.sql` | -| Migration 016 | `backend/migrations/016_fix_poi_media_constraints.sql` | -| Migration script | `backend/scripts/migrate-primary-images.js` | -| Full troubleshooting | `PROD_TROUBLESHOOT.md` | -| Deployment runbook | `.specify/specs/004-multi-image-poi/DEPLOYMENT_RUNBOOK.md` | - ---- - -## Quick Tests (Frontend) - -1. Open https://rootsofthevalley.org -2. Open browser DevTools (F12) → Console tab -3. Click any POI marker -4. Check for errors in console -5. Verify images display in sidebar - -**Common console errors:** -- `Failed to load media:` → API endpoint issue -- `Image not found` → Thumbnail endpoint issue -- `net::ERR_FAILED` → Asset proxy issue - ---- - -## Support - -**GitHub Issue:** https://github.com/crunchtools/rotv/issues -**PR #182:** https://github.com/crunchtools/rotv/pull/182 -**Server:** lotor.dc3.crunchtools.com:22422 -**Service:** rootsofthevalley.org diff --git a/PROD_ISSUE_FLOWCHART.md b/PROD_ISSUE_FLOWCHART.md deleted file mode 100644 index 2c82832d..00000000 --- a/PROD_ISSUE_FLOWCHART.md +++ /dev/null @@ -1,305 +0,0 @@ -# Production Issue Flowchart (PR #182) - -## Issue Flow: "Failed to load image" - -``` -User clicks POI on map - ↓ -Frontend: Sidebar.jsx loads - ↓ -API Call #1: GET /api/pois/${id}/media - ↓ -Backend: server.js:1008-1086 - ↓ - Query: SELECT * FROM poi_media WHERE poi_id = $1 - ↓ - Result: [] (empty - table has no rows) - ↓ - Response: { mosaic: [], all_media: [], total_count: 0 } - ↓ -Frontend receives empty media array - ↓ -Checks: media.length > 0? → NO - ↓ -Checks: has_primary_image = true? → YES (database flag still set) - ↓ -Fallback: Load single image via legacy endpoint - ↓ -API Call #2: GET /api/pois/${id}/thumbnail - ↓ -Backend: server.js:957-997 (CHANGED IN PR #182) - ↓ - OLD CODE (before PR #182): - ↓ - Query image server directly - ↓ - Return image data - - NEW CODE (PR #182): - ↓ - Query: SELECT image_server_asset_id - FROM poi_media - WHERE poi_id = $1 AND role = 'primary' - ↓ - Result: [] (empty - no rows!) - ↓ - Return 404: "Image not found" - ↓ - ❌ ERROR: "Failed to load image" -``` - ---- - -## Root Cause Diagram - -``` -PR #182 Changed Image Serving Logic - ↓ -┌───────────────────────────────────────────────────┐ -│ BEFORE: Direct image server queries │ -│ │ -│ /api/pois/:id/thumbnail │ -│ → imageServerClient.getPrimaryAsset(poiId) │ -│ → Fetch from image server │ -│ → Return image │ -└───────────────────────────────────────────────────┘ - ↓ -┌───────────────────────────────────────────────────┐ -│ AFTER: Database-backed with poi_media table │ -│ │ -│ /api/pois/:id/thumbnail │ -│ → SELECT FROM poi_media WHERE role='primary' │ -│ → Get image_server_asset_id │ -│ → Fetch from image server using assetId │ -│ → Return image │ -└───────────────────────────────────────────────────┘ - ↓ -┌───────────────────────────────────────────────────┐ -│ REQUIRED: poi_media table must be populated │ -│ │ -│ Migration 015: ✅ Creates table structure │ -│ Migration script: ❌ Populates with data │ -│ (NOT RUN IN PROD) │ -└───────────────────────────────────────────────────┘ - ↓ - RESULT: 404 errors on all image requests -``` - ---- - -## What Should Have Happened (Deployment Steps) - -``` -1. ✅ Merge PR #182 -2. ✅ GHA builds new container -3. ✅ Backup database -4. ✅ Apply migration 015 (creates poi_media table) -5. ❌ Run migrate-primary-images.js ← SKIPPED -6. ❌ Apply migration 016 (data integrity) ← MAYBE SKIPPED -7. ✅ Pull new container -8. ✅ Restart service -9. ❌ Verify images load ← WOULD HAVE CAUGHT THIS -``` - -**Missing:** Steps 5, 6, and 9 from deployment runbook - ---- - -## Data Flow (When Working Correctly) - -``` -Database: pois table -┌─────────────────────────────────────────┐ -│ id │ name │ has_primary_image │ -├────┼───────────────┼───────────────────┤ -│ 1 │ Trailhead A │ true │ -│ 2 │ Historical B │ true │ -└─────────────────────────────────────────┘ - ↓ -Migration Script: migrate-primary-images.js - ↓ - 1. Queries image server for primary assets - 2. For each POI with has_primary_image=true - 3. Creates record in poi_media table - ↓ -Database: poi_media table -┌────────────────────────────────────────────────────────────┐ -│ poi_id │ role │ image_server_asset_id │ moderation │ -├────────┼─────────┼───────────────────────┼──────────────┤ -│ 1 │ primary │ abc123 │ published │ -│ 2 │ primary │ def456 │ published │ -└────────────────────────────────────────────────────────────┘ - ↓ -API: GET /api/pois/1/thumbnail - ↓ - SELECT image_server_asset_id FROM poi_media - WHERE poi_id = 1 AND role = 'primary' - ↓ - Result: 'abc123' - ↓ - Fetch from image server: /api/assets/abc123/thumbnail - ↓ - Return image data to browser - ↓ - ✅ Image displays -``` - ---- - -## Current Broken State - -``` -Database: pois table -┌─────────────────────────────────────────┐ -│ id │ name │ has_primary_image │ -├────┼───────────────┼───────────────────┤ -│ 1 │ Trailhead A │ true │ ← Flag still set -│ 2 │ Historical B │ true │ -└─────────────────────────────────────────┘ - ↓ -❌ Migration script NOT RUN - ↓ -Database: poi_media table -┌────────────────────────────────────────────────────────────┐ -│ poi_id │ role │ image_server_asset_id │ moderation │ -├────────┼─────────┼───────────────────────┼──────────────┤ -│ (empty)│ │ │ │ ← NO DATA -└────────────────────────────────────────────────────────────┘ - ↓ -API: GET /api/pois/1/thumbnail - ↓ - SELECT image_server_asset_id FROM poi_media - WHERE poi_id = 1 AND role = 'primary' - ↓ - Result: [] (no rows) - ↓ - Return 404: "Image not found" - ↓ - ❌ "Failed to load image" error in browser -``` - ---- - -## Fix Flow - -``` -Run: migrate-primary-images.js - ↓ - 1. Queries: SELECT id FROM pois WHERE has_primary_image = true - ↓ - Found: [1, 2, 3, ..., 75] - ↓ - 2. For each POI: - ↓ - a. Check if already has primary in poi_media - → Skip if exists (prevents duplicates) - ↓ - b. Fetch primary asset from image server - → GET http://10.89.1.100:8000/api/assets?poi_id=1&role=primary - ↓ - c. Create poi_media record - → INSERT INTO poi_media (poi_id, role, image_server_asset_id, ...) - VALUES (1, 'primary', 'abc123', ...) - ↓ - 3. Summary: - ✓ Migrated: 75 - ✓ Skipped: 0 (already exists) - ✓ Failed: 0 (no asset found) - ↓ -Database: poi_media now populated - ↓ -Restart service - ↓ -Images load correctly ✅ -``` - ---- - -## Verification Flow - -``` -1. Database Check - ↓ - podman exec rootsofthevalley.org psql -U postgres -d rotv \ - -c "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - ↓ - Expected: 75 (or number of POIs with images) - ↓ -2. API Check - ↓ - curl https://rootsofthevalley.org/api/pois/1/media - ↓ - Expected: { "mosaic": [...], "all_media": [...], "total_count": N } - ↓ -3. Frontend Check - ↓ - Open browser → Click POI - ↓ - Expected: Images display, no console errors - ↓ - ✅ Fix verified -``` - ---- - -## Key Files Changed in PR #182 - -### Backend Changes -- `backend/server.js:957-997` - Thumbnail endpoint now queries `poi_media` -- `backend/server.js:1008-1086` - New media endpoint -- `backend/server.js:1229-1299` - Asset proxy endpoints -- `backend/migrations/015_add_poi_media.sql` - Creates table -- `backend/migrations/016_fix_poi_media_constraints.sql` - Data integrity -- `backend/scripts/migrate-primary-images.js` - Populates table - -### Frontend Changes -- `frontend/src/components/Sidebar.jsx:232-241` - Calls new media endpoint -- `frontend/src/components/Mosaic.jsx` - New component -- `frontend/src/components/Lightbox.jsx` - New component -- `frontend/src/components/MediaUploadModal.jsx` - New component - ---- - -## Migration Dependencies - -``` -Migration 015 (SQL) - ↓ -Creates poi_media table structure - ↓ -Migration Script (Node.js) - ↓ -Populates poi_media with data from image server - ↓ -Migration 016 (SQL) - ↓ -Adds data integrity constraints - ↓ -Service Restart - ↓ -New code can serve images from poi_media -``` - -**Critical:** Steps must be done in order. Migration script depends on table existing (015) but should be run before constraints (016) to avoid validation errors during bulk insert. - ---- - -## Why This Wasn't Caught Earlier - -1. **Local testing:** Uses ephemeral database in container - - Migration script runs as part of build - - Always starts fresh - - Would work correctly in dev - -2. **CI/CD:** Doesn't test against production database - - Can't verify data migration - - Only tests code, not deployment - -3. **Deployment:** Manual steps - - Runbook exists but steps were skipped - - No automated verification - -4. **Solution:** Add post-deployment smoke tests - - Check table counts - - Test key API endpoints - - Verify sample POI loads diff --git a/PROD_TROUBLESHOOT.md b/PROD_TROUBLESHOOT.md deleted file mode 100644 index c0e6fc8c..00000000 --- a/PROD_TROUBLESHOOT.md +++ /dev/null @@ -1,345 +0,0 @@ -# Production Troubleshooting: Failed to Load Image (PR #182) - -**Issue:** "Failed to load image" error in production after PR #182 deployment -**Root Cause:** Database migration script not executed -**Date:** 2026-04-04 - ---- - -## Quick Diagnosis - -### Step 1: Check if `poi_media` table exists and has data - -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com - -# Check table exists -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "\dt poi_media" - -# Check if table has any records -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM poi_media;" - -# Check specifically for primary images -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT COUNT(*) FROM poi_media WHERE role='primary';" -``` - -**Expected Results:** -- Table exists: ✅ (migration 015 applied) -- Total count: Should be > 0 if migration script ran -- Primary count: Should match number of POIs with images (likely 50-100+) - -**If count is 0:** The migration script was NOT run ⚠️ - ---- - -## Step 2: Verify Migration 015 Applied - -```bash -# Check if poi_media table has correct schema -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "\d poi_media" -``` - -**Expected Columns:** -- id, poi_id, media_type -- image_server_asset_id, youtube_url -- role, sort_order, likes_count, caption -- moderation_status, confidence_score, ai_reasoning -- submitted_by, moderated_by, moderated_at, created_at - -**If table doesn't exist:** Migration 015 was not applied - ---- - -## Step 3: Verify Migration 016 Applied - -```bash -# Check for caption length constraint -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT conname -FROM pg_constraint -WHERE conrelid = 'poi_media'::regclass - AND conname = 'poi_media_caption_length_check'; -" -``` - -**Expected:** 1 row returned with constraint name - -**If not found:** Migration 016 was not applied - ---- - -## Step 4: Check Image Server Connectivity - -```bash -# Check IMAGE_SERVER_URL environment variable -podman exec rootsofthevalley.org printenv | grep IMAGE_SERVER - -# Test image server from container -podman exec rootsofthevalley.org curl -s http://10.89.1.100:8000/api/health | jq - -# Alternative: Check from host -curl -s http://10.89.1.100:8000/api/health | jq -``` - -**Expected Results:** -- `IMAGE_SERVER_URL=http://10.89.1.100:8000` (or similar) -- Health endpoint returns `{"status": "ok"}` or similar - -**If connection fails:** Image server is down or unreachable - ---- - -## Step 5: Check Application Logs - -```bash -# Check for initialization errors -journalctl -u rootsofthevalley.org --since "1 hour ago" | grep -i "imageserver" - -# Check for 404 errors on media endpoints -journalctl -u rootsofthevalley.org --since "1 hour ago" | grep "/api/pois/.*/media" - -# Check for thumbnail endpoint errors -journalctl -u rootsofthevalley.org --since "1 hour ago" | grep "/api/pois/.*/thumbnail" -``` - -**Look for:** -- `[ImageServer] Initialized with server: http://10.89.1.100:8000` ✅ -- `[ImageServer] Not configured - set IMAGE_SERVER_URL` ❌ -- 404 errors on thumbnail requests -- Database query errors - ---- - -## Fix Procedure - -### Fix 1: Apply Missing Migrations (if `poi_media` is empty) - -```bash -# DRY RUN first to see what would be migrated -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js --dry-run - -# Review output, then run for real -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - -# Verify migration succeeded -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - media_type, - role, - moderation_status, - COUNT(*) -FROM poi_media -GROUP BY media_type, role, moderation_status; -" -``` - -**Expected Output:** -``` - media_type | role | moderation_status | count -------------+---------+-------------------+------- - image | primary | published | 75 -(1 row) -``` - -### Fix 2: Apply Migration 016 (if constraints missing) - -```bash -# Apply data integrity migration -podman exec rootsofthevalley.org psql -U postgres -d rotv -f /app/migrations/016_fix_poi_media_constraints.sql - -# Verify constraints applied -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT conname, contype -FROM pg_constraint -WHERE conrelid = 'poi_media'::regclass -ORDER BY conname; -" -``` - -**Expected Constraints:** -- `poi_media_caption_length_check` (CHECK) -- `poi_media_moderation_check` (CHECK) -- `poi_media_moderated_by_fkey` (FOREIGN KEY) -- `poi_media_submitted_by_fkey` (FOREIGN KEY) - -### Fix 3: Restart Service (if needed) - -```bash -# Restart to clear any caching issues -systemctl restart rootsofthevalley.org - -# Wait for startup -sleep 10 - -# Verify service is running -systemctl status rootsofthevalley.org --no-pager - -# Check logs for errors -journalctl -u rootsofthevalley.org --since "1 minute ago" --no-pager -``` - ---- - -## Verification Tests - -### Test 1: API Endpoints - -```bash -# Test media endpoint for POI #1 -curl -s https://rootsofthevalley.org/api/pois/1/media | jq - -# Should return: -# { -# "mosaic": [...], -# "all_media": [...], -# "total_count": N -# } - -# Test legacy thumbnail endpoint -curl -I https://rootsofthevalley.org/api/pois/1/thumbnail -# Should return: 200 OK (with image data) -``` - -### Test 2: Frontend UI - -1. Navigate to https://rootsofthevalley.org -2. Click any POI marker on the map -3. Sidebar should show: - - Mosaic display (Facebook-style grid) if multiple images - - Single image if only one image - - No broken image icons - -### Test 3: Database Queries - -```bash -# Check media for a specific POI -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT - id, - media_type, - role, - image_server_asset_id, - moderation_status -FROM poi_media -WHERE poi_id = 1; -" - -# Check moderation queue -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT COUNT(*) -FROM moderation_queue -WHERE content_type = 'photo'; -" -``` - ---- - -## Common Issues - -### Issue: "Image server not configured" - -**Symptoms:** Logs show `[ImageServer] Not configured - set IMAGE_SERVER_URL` - -**Fix:** -```bash -# Check systemd service file for IMAGE_SERVER_URL environment variable -systemctl cat rootsofthevalley.org | grep -i image - -# If missing, edit service file and add: -# Environment="IMAGE_SERVER_URL=http://10.89.1.100:8000" - -# Reload and restart -systemctl daemon-reload -systemctl restart rootsofthevalley.org -``` - -### Issue: Migration script fails with "ECONNREFUSED" - -**Symptoms:** `migrate-primary-images.js` can't connect to image server - -**Fix:** -```bash -# Test connectivity from container -podman exec rootsofthevalley.org curl -v http://10.89.1.100:8000/api/health - -# If connection refused, check: -# 1. Image server is running -# 2. Firewall rules allow 10.89.1.100:8000 -# 3. Network routing is correct -``` - -### Issue: Migration script creates duplicates - -**Symptoms:** Unique constraint violation on `idx_poi_media_unique_primary` - -**Fix:** The script checks for existing entries and skips them. If duplicates occur: -```bash -# Find duplicates -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -SELECT poi_id, COUNT(*) -FROM poi_media -WHERE role = 'primary' - AND moderation_status IN ('published', 'auto_approved') -GROUP BY poi_id -HAVING COUNT(*) > 1; -" - -# Manually clean up (keep oldest, delete newer) -podman exec rootsofthevalley.org psql -U postgres -d rotv -c " -DELETE FROM poi_media -WHERE id IN ( - SELECT id - FROM ( - SELECT id, poi_id, - ROW_NUMBER() OVER (PARTITION BY poi_id ORDER BY created_at ASC) as rn - FROM poi_media - WHERE role = 'primary' - AND moderation_status IN ('published', 'auto_approved') - ) sub - WHERE rn > 1 -); -" -``` - ---- - -## Rollback (if needed) - -If deployment is broken beyond repair: - -```bash -# Find backup file -ls -lh /root/backups/rotv_pre_multi_image_* - -# Restore database -podman exec -i rootsofthevalley.org psql -U postgres rotv < /root/backups/rotv_pre_multi_image_.sql - -# Revert to previous container image -podman images quay.io/crunchtools/rotv -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest - -# Restart service -systemctl restart rootsofthevalley.org -``` - ---- - -## Success Checklist - -- [ ] `poi_media` table exists -- [ ] `poi_media` has records (COUNT > 0) -- [ ] Primary images migrated (role='primary' count matches POIs with images) -- [ ] Migration 016 constraints applied -- [ ] Image server connectivity verified -- [ ] API endpoint `/api/pois/1/media` returns data -- [ ] Frontend displays mosaic/images correctly -- [ ] No errors in service logs -- [ ] Legacy thumbnail endpoint works - ---- - -## Contact - -**Issue:** PR #182 - Multi-Image POI Support -**PR Link:** https://github.com/crunchtools/rotv/pull/182 -**Deployment Runbook:** `.specify/specs/004-multi-image-poi/DEPLOYMENT_RUNBOOK.md` diff --git a/README_PRODUCTION.md b/README_PRODUCTION.md deleted file mode 100644 index c923282d..00000000 --- a/README_PRODUCTION.md +++ /dev/null @@ -1,368 +0,0 @@ -# Production Operations - Roots of The Valley - -**Quick Links:** [Deploy](#deployment) • [Troubleshoot](#troubleshooting) • [Incident Response](#incident-response) • [Monitoring](#monitoring) • [Rollback](#rollback) - ---- - -## 🚀 Deployment - -### Quick Deploy (No Migrations) -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -podman pull quay.io/crunchtools/rotv:latest && systemctl restart rootsofthevalley.org -curl -sf https://rootsofthevalley.org/api/health && echo "✅ OK" -``` - -### Full Deployment Guide -See **[DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)** for: -- Standard deployment process -- Deployment with migrations -- Rollback procedures -- Monitoring guidelines -- Common scenarios - -### Post-Deployment Verification -```bash -# Automated report (recommended) -bash scripts/post-deployment-report.sh - -# Or run smoke tests -gh workflow run smoke-test.yml -``` - ---- - -## 🔍 Troubleshooting - -### Quick Health Check (30 seconds) -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com "podman exec rootsofthevalley.org psql -U postgres -d rotv -c \"SELECT 'Total POIs:' as check, COUNT(*)::text FROM pois UNION ALL SELECT 'Media records:', COUNT(*)::text FROM poi_media;\"" -``` - -### Automated Diagnostics -```bash -# Run comprehensive diagnostics -ssh -p 22422 root@lotor.dc3.crunchtools.com -bash scripts/diagnose-production.sh -``` - -### Troubleshooting Resources - -| Issue | Resource | -|-------|----------| -| General issues | **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** | -| Quick fixes | **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** | -| Images not loading | **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** | -| Understanding data flow | **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** | -| Migration issues | Run `scripts/verify-migrations.sh` | - ---- - -## 🚨 Incident Response - -### Active Incident? Start Here - -**[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** - Choose your path: - -1. **Just Fix It (5 min)** - Copy-paste commands for immediate resolution -2. **Diagnose First (10 min)** - Run automated diagnostics then fix -3. **Understand First (20 min)** - Deep dive then fix - -### Incident Response Checklist - -- [ ] Identify symptoms (what's broken?) -- [ ] Check service status: `systemctl status rootsofthevalley.org` -- [ ] Review recent logs: `journalctl -u rootsofthevalley.org --since "1 hour ago"` -- [ ] Run diagnostics: `bash scripts/diagnose-production.sh` -- [ ] Determine severity (critical? major? minor?) -- [ ] Follow appropriate fix procedure -- [ ] Verify fix worked -- [ ] Document incident (use **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** template) - -### Common Incidents - -#### Images Not Loading -```bash -# Diagnosis -podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - -# Fix (if count is 0) -podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js -systemctl restart rootsofthevalley.org -``` - -See **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** for more quick fixes. - -#### Service Won't Start -```bash -# Check logs -journalctl -u rootsofthevalley.org --no-pager -n 50 - -# Common causes: -# - Database not ready -# - Port conflict -# - Migration failure -# - Configuration error - -# See DEPLOYMENT_GUIDE.md → Troubleshooting Deployments -``` - ---- - -## 📊 Monitoring - -### Real-Time Monitoring -```bash -# Watch logs -journalctl -u rootsofthevalley.org -f - -# Watch resource usage -podman stats rootsofthevalley.org -``` - -### Periodic Health Checks - -```bash -# Every 10 minutes (first hour after deployment) -curl -sf https://rootsofthevalley.org/api/health - -# Every 4 hours (first 24 hours) -bash scripts/post-deployment-report.sh - -# Weekly -gh workflow run smoke-test.yml -``` - -### Key Metrics to Monitor - -| Metric | Command | Healthy Value | -|--------|---------|---------------| -| Service status | `systemctl status rootsofthevalley.org` | active (running) | -| Error rate | `journalctl -u rootsofthevalley.org --since "1 hour ago" \| grep -i error \| wc -l` | < 10 per hour | -| Response time | `time curl -s https://rootsofthevalley.org/api/pois/1/media > /dev/null` | < 1 second | -| Database size | `podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT pg_size_pretty(pg_database_size('rotv'));"` | Grows steadily | -| Media count | `podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc "SELECT COUNT(*) FROM poi_media;"` | Grows over time | - ---- - -## ↩️ Rollback - -### Quick Rollback (Container Only) -```bash -# Revert to previous container (2 minutes) -ssh -p 22422 root@lotor.dc3.crunchtools.com -podman images quay.io/crunchtools/rotv -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest -systemctl restart rootsofthevalley.org -``` - -### Full Rollback (Container + Database) -```bash -# Restore everything (5 minutes) -ssh -p 22422 root@lotor.dc3.crunchtools.com -ls -lht /root/backups/rotv_* | head -5 -podman exec -i rootsofthevalley.org psql -U postgres rotv < /root/backups/rotv_TIMESTAMP.sql -podman tag quay.io/crunchtools/rotv: quay.io/crunchtools/rotv:latest -systemctl restart rootsofthevalley.org -``` - -See **[DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md#rollback-procedures)** for detailed rollback procedures. - ---- - -## 🛠️ Scripts Reference - -| Script | Purpose | Usage | -|--------|---------|-------| -| **diagnose-production.sh** | Automated diagnostics | `bash scripts/diagnose-production.sh` | -| **fix-production.sh** | Automated fix with backup | `bash scripts/fix-production.sh` | -| **verify-migrations.sh** | Verify migrations applied | `bash scripts/verify-migrations.sh` | -| **post-deployment-report.sh** | Generate deployment report | `bash scripts/post-deployment-report.sh` | - -All scripts should be run on production server after SSH. - ---- - -## 📚 Documentation Index - -### Deployment & Operations -- **[DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)** - Complete deployment guide -- **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** - Post-deployment checklist - -### Troubleshooting -- **[PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md)** - Comprehensive troubleshooting -- **[PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md)** - Quick reference commands -- **[PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md)** - Visual debugging guide - -### Incident Response -- **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** - Incident response guide -- **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** - Executive summary template - -### Development -- **[CLAUDE.md](./CLAUDE.md)** - Development guidelines -- **[docs/DEVELOPMENT_ARCHITECTURE.md](./docs/DEVELOPMENT_ARCHITECTURE.md)** - System architecture -- **[.specify/memory/constitution.md](./.specify/memory/constitution.md)** - Project constitution - ---- - -## 🔗 Quick Reference Links - -### Production Environment -- **URL:** https://rootsofthevalley.org -- **Server:** lotor.dc3.crunchtools.com:22422 -- **Container:** rootsofthevalley.org -- **Registry:** quay.io/crunchtools/rotv:latest -- **Database:** PostgreSQL 17 (rotv) - -### SSH Access -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -``` - -### GitHub Actions -```bash -# Trigger smoke tests -gh workflow run smoke-test.yml - -# Check recent builds -gh run list --workflow=build.yml --limit 5 - -# Monitor running workflow -gh run watch -``` - -### Key API Endpoints -- Health: https://rootsofthevalley.org/api/health -- POIs: https://rootsofthevalley.org/api/pois -- Media: https://rootsofthevalley.org/api/pois/{id}/media -- Thumbnails: https://rootsofthevalley.org/api/pois/{id}/thumbnail - ---- - -## 📞 Support & Escalation - -### When to Escalate -- Service down for > 15 minutes -- Data loss detected -- Security incident -- Unable to resolve using troubleshooting guides - -### Escalation Path -1. Review all troubleshooting documents -2. Run automated diagnostics -3. Attempt rollback if appropriate -4. Document incident details -5. Create GitHub issue with details -6. Contact deployment owner - -### Contact -- **GitHub Issues:** https://github.com/crunchtools/rotv/issues -- **Deployment Owner:** Scott McCarty (@fatherlinux) - ---- - -## 🎓 Learning Resources - -### Recent Incidents & Lessons Learned - -#### PR #182: Image Loading Failure (2026-04-04) -**Issue:** Images not loading after deployment -**Cause:** Migration script not executed -**Resolution:** Run `migrate-primary-images.js` script -**Lessons:** -- Always verify migrations after deployment -- Use post-deployment verification checklist -- Automate smoke tests in CI/CD - -**Full Documentation:** -- **[PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md)** -- **[EXEC_SUMMARY.md](./EXEC_SUMMARY.md)** - ---- - -## 🔄 Continuous Improvement - -### After Every Deployment -- [ ] Generate post-deployment report -- [ ] Review any issues encountered -- [ ] Update runbooks if needed -- [ ] Add to lessons learned - -### After Every Incident -- [ ] Document incident details -- [ ] Perform root cause analysis -- [ ] Update troubleshooting guides -- [ ] Implement prevention measures -- [ ] Add to monitoring/alerts - -### Quarterly Review -- [ ] Review all incidents -- [ ] Identify patterns -- [ ] Update automation -- [ ] Improve monitoring -- [ ] Train team on new procedures - ---- - -## 📋 Checklists - -### Pre-Deployment -- [ ] All tests passing -- [ ] Security scans clean -- [ ] Migrations identified and ready -- [ ] Backup strategy confirmed -- [ ] Rollback procedure understood - -### During Deployment -- [ ] Backup created -- [ ] Migrations applied -- [ ] Migrations verified -- [ ] Service restarted -- [ ] Service healthy - -### Post-Deployment -- [ ] Health checks passing -- [ ] Feature tests passed -- [ ] Smoke tests run -- [ ] Report generated -- [ ] Monitoring active - -See **[DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md)** for detailed checklist. - ---- - -## 🆘 Emergency Contacts & Quick Commands - -### Immediate Response Commands - -```bash -# Stop the service (emergency only) -systemctl stop rootsofthevalley.org - -# Check if service is running -systemctl status rootsofthevalley.org - -# View last 50 log lines -journalctl -u rootsofthevalley.org --no-pager -n 50 - -# Check database connection -podman exec rootsofthevalley.org psql -U postgres -d rotv -c "SELECT 1;" - -# Check disk space -df -h - -# Check container resource usage -podman stats --no-stream rootsofthevalley.org -``` - -### Emergency Rollback -```bash -# One-liner rollback to previous container -podman tag quay.io/crunchtools/rotv:$(podman images quay.io/crunchtools/rotv --format "{{.Tag}}" | grep -v latest | head -1) quay.io/crunchtools/rotv:latest && systemctl restart rootsofthevalley.org -``` - ---- - -**Last Updated:** 2026-04-04 -**Version:** 1.0 -**Maintainer:** Scott McCarty (@fatherlinux) diff --git a/TROUBLESHOOTING_PACKAGE_INDEX.md b/TROUBLESHOOTING_PACKAGE_INDEX.md deleted file mode 100644 index 18d084a8..00000000 --- a/TROUBLESHOOTING_PACKAGE_INDEX.md +++ /dev/null @@ -1,466 +0,0 @@ -# Troubleshooting Package - Complete Index - -**Created:** 2026-04-04 -**Purpose:** Production issue response for PR #182 image loading failure -**Status:** Complete and ready for use - ---- - -## 📦 Package Contents - -This comprehensive troubleshooting package provides everything needed to diagnose, fix, and prevent production issues. - -### 🎯 Start Here - -**[README_PRODUCTION.md](./README_PRODUCTION.md)** - Main production operations guide -- Quick reference for all production tasks -- Links to all resources -- Emergency commands -- Quick health checks - -### 📋 Documentation Structure - -``` -Production Operations -├── README_PRODUCTION.md .................. Main entry point -├── DEPLOYMENT_GUIDE.md ................... Complete deployment guide -├── DEPLOYMENT_VERIFICATION_CHECKLIST.md .. Post-deployment checklist -│ -Incident Response -├── PRODUCTION_INCIDENT_README.md ......... Incident response guide -├── EXEC_SUMMARY.md ....................... Executive summary template -│ -Troubleshooting -├── PROD_TROUBLESHOOT.md .................. Comprehensive troubleshooting -├── PROD_FIX_QUICKREF.md .................. Quick reference commands -├── PROD_ISSUE_FLOWCHART.md ............... Visual debugging guide -│ -Scripts -├── scripts/diagnose-production.sh ........ Automated diagnostics -├── scripts/fix-production.sh ............. Automated fix with backup -├── scripts/verify-migrations.sh .......... Migration verification -└── scripts/post-deployment-report.sh ..... Deployment report generator - -Automation -└── .github/workflows/smoke-test.yml ...... Smoke tests (GitHub Actions) -``` - ---- - -## 🚀 Quick Start Guides - -### Scenario 1: Images Not Loading (PR #182) -**Time: 5 minutes** - -1. **Diagnose:** - ```bash - ssh -p 22422 root@lotor.dc3.crunchtools.com - podman exec rootsofthevalley.org psql -U postgres -d rotv -tAc \ - "SELECT COUNT(*) FROM poi_media WHERE role='primary';" - # If 0, migration script wasn't run - ``` - -2. **Fix:** - ```bash - podman exec rootsofthevalley.org node /app/scripts/migrate-primary-images.js - systemctl restart rootsofthevalley.org - ``` - -3. **Verify:** - ```bash - curl -s https://rootsofthevalley.org/api/pois/1/media | jq '.total_count' - # Should return > 0 - ``` - -**Resources:** [PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md) - -### Scenario 2: Full Health Check -**Time: 2 minutes** - -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -bash scripts/diagnose-production.sh -``` - -**Resources:** [scripts/diagnose-production.sh](./scripts/diagnose-production.sh) - -### Scenario 3: Post-Deployment Verification -**Time: 3 minutes** - -```bash -ssh -p 22422 root@lotor.dc3.crunchtools.com -bash scripts/post-deployment-report.sh -``` - -**Resources:** [DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md) - ---- - -## 📚 Documentation by Use Case - -### For Deployers -**Primary:** [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) -- Standard deployment process -- Deployment with migrations -- Rollback procedures -- Common scenarios - -**Secondary:** -- [DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md) -- [scripts/post-deployment-report.sh](./scripts/post-deployment-report.sh) - -### For On-Call Engineers -**Primary:** [README_PRODUCTION.md](./README_PRODUCTION.md) -- Quick reference -- Emergency commands -- Common issues - -**Secondary:** -- [PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md) -- [scripts/diagnose-production.sh](./scripts/diagnose-production.sh) - -### For Incident Response -**Primary:** [PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md) -- Three resolution paths (fast/medium/thorough) -- Incident flow -- Testing checklist - -**Secondary:** -- [EXEC_SUMMARY.md](./EXEC_SUMMARY.md) - For stakeholder communication -- [PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md) - Visual understanding - -### For Troubleshooting -**Primary:** [PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md) -- Diagnostic steps -- Fix procedures -- Common errors - -**Secondary:** -- [PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md) - Data flow diagrams -- [scripts/diagnose-production.sh](./scripts/diagnose-production.sh) - Automated diagnostics - -### For Executives/Stakeholders -**Primary:** [EXEC_SUMMARY.md](./EXEC_SUMMARY.md) -- What happened -- Impact assessment -- Resolution time -- Prevention measures - -**Secondary:** -- [PRODUCTION_INCIDENT_README.md](./PRODUCTION_INCIDENT_README.md) - Incident details - ---- - -## 🛠️ Scripts Reference - -### Diagnostic Scripts - -#### diagnose-production.sh -**Purpose:** Comprehensive automated diagnostics -**Time:** 30 seconds -**Output:** Pass/fail checks with recommendations - -```bash -bash scripts/diagnose-production.sh -``` - -**Checks:** -- Container status -- Database tables -- Record counts -- Migrations applied -- API endpoints -- Error rates - -#### verify-migrations.sh -**Purpose:** Verify all database migrations -**Time:** 20 seconds -**Output:** Detailed migration status - -```bash -bash scripts/verify-migrations.sh -``` - -**Checks:** -- Table existence -- Column schemas -- Indexes -- Constraints -- Data population -- Data integrity - -### Fix Scripts - -#### fix-production.sh -**Purpose:** Automated fix with backup -**Time:** 5 minutes -**Interactive:** Yes (asks for confirmation) - -```bash -bash scripts/fix-production.sh -``` - -**Actions:** -- Creates database backup -- Applies migration 016 -- Runs primary image migration -- Restarts service -- Verifies fix - -### Reporting Scripts - -#### post-deployment-report.sh -**Purpose:** Generate deployment health report -**Time:** 10 seconds -**Output:** Markdown report file - -```bash -bash scripts/post-deployment-report.sh -``` - -**Includes:** -- Service status -- Database health -- API endpoint status -- Migration status -- Recent errors -- Recommendations - ---- - -## 🔄 GitHub Actions Workflows - -### smoke-test.yml -**Trigger:** Manual (`gh workflow run smoke-test.yml`) -**Purpose:** Post-deployment smoke tests -**Time:** 2-3 minutes - -**Tests:** -1. Health endpoint -2. POI list endpoint -3. Media endpoint (PR #182 critical) -4. Thumbnail endpoint -5. Asset proxy SSRF protection -6. Auth status endpoint -7. Frontend loads -8. Database connectivity -9. Response time check - -**Usage:** -```bash -# Trigger from local machine -gh workflow run smoke-test.yml - -# Monitor progress -gh run watch - -# View results -gh run view -``` - ---- - -## 📖 Reading Paths - -### Path 1: "Just Fix It" (5 minutes) -For experienced ops engineers who need immediate resolution: - -1. [PROD_FIX_QUICKREF.md](./PROD_FIX_QUICKREF.md) → Copy-paste commands -2. Execute fix -3. Verify with quick health check - -### Path 2: "Diagnose Then Fix" (10 minutes) -For methodical troubleshooting: - -1. Run `scripts/diagnose-production.sh` -2. Review output -3. Run `scripts/fix-production.sh` (if needed) -4. Verify with `scripts/post-deployment-report.sh` - -### Path 3: "Understand First" (30 minutes) -For learning and prevention: - -1. [EXEC_SUMMARY.md](./EXEC_SUMMARY.md) - What happened -2. [PROD_ISSUE_FLOWCHART.md](./PROD_ISSUE_FLOWCHART.md) - How it works -3. [PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md) - Detailed diagnosis -4. [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - Prevent recurrence -5. Apply fix -6. Review [DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md) - -### Path 4: "New to Production Ops" (1 hour) -For onboarding: - -1. [README_PRODUCTION.md](./README_PRODUCTION.md) - Overview -2. [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - How to deploy -3. [DEPLOYMENT_VERIFICATION_CHECKLIST.md](./DEPLOYMENT_VERIFICATION_CHECKLIST.md) - Checklist -4. [PROD_TROUBLESHOOT.md](./PROD_TROUBLESHOOT.md) - Common issues -5. Practice: Run `scripts/diagnose-production.sh` - ---- - -## 🎯 Key Features - -### Automation -- ✅ Automated diagnostics (diagnose-production.sh) -- ✅ Automated fix with backup (fix-production.sh) -- ✅ Migration verification (verify-migrations.sh) -- ✅ Post-deployment reporting (post-deployment-report.sh) -- ✅ Smoke tests (GitHub Actions) - -### Documentation -- ✅ Multiple reading paths (fast/thorough) -- ✅ Visual diagrams (flowcharts, data flow) -- ✅ Copy-paste commands (no guessing) -- ✅ Comprehensive troubleshooting guide -- ✅ Executive summaries (stakeholder communication) - -### Prevention -- ✅ Deployment verification checklist -- ✅ Post-deployment smoke tests -- ✅ Migration verification -- ✅ Lessons learned documented -- ✅ Rollback procedures - ---- - -## 📊 Package Statistics - -- **Total Documents:** 11 -- **Total Scripts:** 4 -- **Total Workflows:** 1 -- **Total Lines of Documentation:** ~4,500 -- **Total Lines of Code (scripts):** ~800 -- **Copy-Paste Commands:** 50+ -- **Diagnostic Checks:** 30+ -- **Coverage:** Complete incident lifecycle - ---- - -## 🔗 Cross-References - -### From Issue to Resolution - -``` -User Reports Issue - ↓ -README_PRODUCTION.md (start here) - ↓ -Choose Path: - ├─ Fast → PROD_FIX_QUICKREF.md - ├─ Diagnostic → scripts/diagnose-production.sh - └─ Deep Dive → PROD_TROUBLESHOOT.md - ↓ -Apply Fix - ├─ Automated → scripts/fix-production.sh - └─ Manual → PROD_FIX_QUICKREF.md - ↓ -Verify Fix - ├─ Quick → curl health check - └─ Thorough → scripts/post-deployment-report.sh - ↓ -Document Incident - └─ EXEC_SUMMARY.md template - ↓ -Prevent Recurrence - └─ DEPLOYMENT_VERIFICATION_CHECKLIST.md -``` - -### Deployment Flow - -``` -Merge PR - ↓ -GitHub Actions Build - ↓ -DEPLOYMENT_GUIDE.md - ├─ Standard deployment - └─ Migration deployment - ↓ -Apply Changes - ├─ Container update - └─ Database migrations - ↓ -Verify Deployment - ├─ scripts/post-deployment-report.sh - ├─ DEPLOYMENT_VERIFICATION_CHECKLIST.md - └─ smoke-test.yml (GitHub Actions) - ↓ -Monitor - └─ README_PRODUCTION.md → Monitoring section -``` - ---- - -## 🎓 Learning Outcomes - -After using this package, operators will be able to: - -1. **Diagnose** production issues in < 2 minutes -2. **Fix** common issues in < 5 minutes -3. **Verify** deployments systematically -4. **Rollback** safely when needed -5. **Document** incidents for stakeholders -6. **Prevent** issues through checklists -7. **Automate** common tasks -8. **Communicate** effectively during incidents - ---- - -## 🔄 Maintenance - -### When to Update This Package - -- After every production incident (add to lessons learned) -- After major feature deployments (update procedures) -- Quarterly review (refresh and improve) -- When automation improves (update scripts) - -### How to Update - -1. Document new issues in PROD_TROUBLESHOOT.md -2. Add commands to PROD_FIX_QUICKREF.md -3. Update scripts with new checks -4. Add to DEPLOYMENT_VERIFICATION_CHECKLIST.md -5. Update this index - ---- - -## 📞 Feedback & Improvement - -### Report Issues -- GitHub Issues: https://github.com/crunchtools/rotv/issues -- Tag issues with `documentation` or `operations` - -### Suggest Improvements -- Better automation -- Missing scenarios -- Unclear documentation -- New monitoring needs - ---- - -## ✅ Quality Checklist - -This package includes: - -- [x] Quick start guides (< 5 minutes to resolution) -- [x] Comprehensive troubleshooting (all scenarios covered) -- [x] Automated diagnostics (no manual checks needed) -- [x] Automated fixes (safe with backups) -- [x] Visual diagrams (data flow, flowcharts) -- [x] Copy-paste commands (no guessing) -- [x] Rollback procedures (tested and safe) -- [x] Verification checklists (prevent issues) -- [x] Incident templates (stakeholder communication) -- [x] Prevention measures (lessons learned) -- [x] Cross-references (easy navigation) -- [x] Multiple reading paths (all skill levels) - ---- - -**Created By:** Claude Sonnet 4.5 (AI Assistant) -**Reviewed By:** Pending -**Version:** 1.0 -**Last Updated:** 2026-04-04 - -**Next Review:** After next production incident or quarterly diff --git a/backend/config/passport.js b/backend/config/passport.js index d2e9f783..970022f8 100644 --- a/backend/config/passport.js +++ b/backend/config/passport.js @@ -102,10 +102,11 @@ export function configurePassport(pool) { })); // Upgrade strategy - Drive scope for admin only (incremental authorization) + // Uses same callback URL as standard strategy to avoid multiple OAuth app configurations passport.use('google-upgrade', new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: '/auth/google/upgrade/callback', + callbackURL: process.env.GOOGLE_CALLBACK_URL || '/auth/google/callback', scope: ['profile', 'email', 'https://www.googleapis.com/auth/drive.file'] }, async (accessToken, refreshToken, profile, done) => { try { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 3380e487..6be11361 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -15,13 +15,38 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { // Standard Google OAuth (all users - basic scopes only) router.get('/google', passport.authenticate('google')); - router.get('/google/callback', - passport.authenticate('google', { failureRedirect: `${FRONTEND_URL}?auth=failed` }), - async (req, res) => { - // Auto-detect admin without Drive credentials + // Drive scope upgrade (admin only - incremental authorization) + router.get('/google/upgrade', passport.authenticate('google-upgrade', { + accessType: 'offline', + prompt: 'consent', + state: 'upgrade' // Pass state to identify upgrade flow in callback + })); + + // Unified callback handler - handles both standard and upgrade flows + router.get('/google/callback', (req, res, next) => { + // Check if this is an upgrade callback (state=upgrade) + const isUpgrade = req.query.state === 'upgrade'; + const strategy = isUpgrade ? 'google-upgrade' : 'google'; + + passport.authenticate(strategy, { + failureRedirect: `${FRONTEND_URL}?auth=failed` + })(req, res, async () => { + // Handle upgrade flow - redirect to Sync Settings + if (isUpgrade) { + return res.redirect(`${FRONTEND_URL}/admin?auth=success&tab=sync`); + } + + // Handle standard flow - auto-detect admin without Drive credentials const isAdmin = req.user.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase(); - const hasCredentials = req.user.oauth_credentials && - JSON.parse(req.user.oauth_credentials).access_token; + + // Parse credentials (handles both JSON string and object from pg driver) + let credentials = null; + if (req.user.oauth_credentials) { + credentials = typeof req.user.oauth_credentials === 'string' + ? JSON.parse(req.user.oauth_credentials) + : req.user.oauth_credentials; + } + const hasCredentials = credentials && credentials.access_token; if (isAdmin && !hasCredentials) { // Redirect admin to upgrade flow for Drive access @@ -30,22 +55,8 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { // Standard success redirect res.redirect(`${FRONTEND_URL}?auth=success`); - } - ); - - // Drive scope upgrade (admin only - incremental authorization) - router.get('/google/upgrade', passport.authenticate('google-upgrade', { - accessType: 'offline', - prompt: 'consent' - })); - - router.get('/google/upgrade/callback', - passport.authenticate('google-upgrade', { failureRedirect: `${FRONTEND_URL}?auth=failed` }), - (req, res) => { - // Redirect to Sync Settings after Drive access granted - res.redirect(`${FRONTEND_URL}/admin?auth=success&tab=sync`); - } - ); + }); + }); } else { // Return helpful error when OAuth not configured router.get('/google', (req, res) => { From 59642b44fe9ffe395a6615815a7a7bd669464daa Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 01:10:12 -0400 Subject: [PATCH 5/7] fix: address Gemini security and stability concerns Fixes all 5 issues identified by Gemini code review: 1. Remove hardcoded admin email fallback (security) - Changed ADMIN_EMAIL fallback from personal email to empty string - Forces proper environment variable configuration 2. Add try-catch for JSON.parse of oauth_credentials (stability) - Prevents crash on invalid JSON in database - Logs error and continues with null credentials 3. Remove internal error details from 500 responses (security) - Prevents leaking implementation details to clients - Still logs full error server-side for debugging 4. Wrap media delete in transaction (data integrity) - Uses BEGIN/COMMIT/ROLLBACK like set-primary endpoint - Ensures has_primary_image flag stays consistent with media state - Prevents orphaned POI state if UPDATE fails after DELETE 5. Re-enable React.StrictMode (code quality) - Restores development-time checks for side effects - Helps catch deprecated API usage and other issues --- backend/routes/admin.js | 2 +- backend/routes/auth.js | 13 +++++++++---- backend/server.js | 26 ++++++++++++++++---------- frontend/src/main.jsx | 8 +++++--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 534eb966..a308ce52 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -3932,7 +3932,7 @@ export function createAdminRouter(pool, invalidateMosaicCache) { } catch (error) { console.error('[Moderation Save] Error:', error.message); console.error('[Moderation Save] Stack:', error.stack); - res.status(500).json({ error: 'Failed to save edits', details: error.message }); + res.status(500).json({ error: 'Failed to save edits' }); } }); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 6be11361..19b39363 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -4,7 +4,7 @@ import passport from 'passport'; const router = express.Router(); const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8080'; -const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'scott.mccarty@gmail.com'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || ''; // Google OAuth - dual-strategy approach for conditional Drive access // Standard route: all users authenticate with basic scopes (profile + email) @@ -42,9 +42,14 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { // Parse credentials (handles both JSON string and object from pg driver) let credentials = null; if (req.user.oauth_credentials) { - credentials = typeof req.user.oauth_credentials === 'string' - ? JSON.parse(req.user.oauth_credentials) - : req.user.oauth_credentials; + try { + credentials = typeof req.user.oauth_credentials === 'string' + ? JSON.parse(req.user.oauth_credentials) + : req.user.oauth_credentials; + } catch (err) { + console.error('Failed to parse oauth_credentials:', err); + credentials = null; + } } const hasCredentials = credentials && credentials.access_token; diff --git a/backend/server.js b/backend/server.js index 98f8fda9..c37a079c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1270,19 +1270,12 @@ app.delete('/api/pois/:poiId/media/:mediaId', isAuthenticated, async (req, res) return res.status(403).json({ error: 'You can only delete your own media' }); } + // Transaction: delete media and update POI flag atomically + await pool.query('BEGIN'); + // Delete from database await pool.query('DELETE FROM poi_media WHERE id = $1', [mediaId]); - // Delete from image server (if it's an image/video, not YouTube) - if (media.image_server_asset_id) { - try { - await imageServerClient.deleteAsset(media.image_server_asset_id); - } catch (err) { - console.error('Failed to delete asset from image server:', err); - // Continue anyway - DB record is deleted - } - } - // Update POI's has_primary_image flag based on remaining media const remainingPrimary = await pool.query( `SELECT id FROM poi_media @@ -1298,11 +1291,24 @@ app.delete('/api/pois/:poiId/media/:mediaId', isAuthenticated, async (req, res) [remainingPrimary.rows.length > 0, poiId] ); + await pool.query('COMMIT'); + + // Delete from image server (if it's an image/video, not YouTube) + if (media.image_server_asset_id) { + try { + await imageServerClient.deleteAsset(media.image_server_asset_id); + } catch (err) { + console.error('Failed to delete asset from image server:', err); + // Continue anyway - DB record is already deleted + } + } + // Invalidate mosaic cache invalidateMosaicCache(poiId); res.json({ success: true, message: 'Media deleted' }); } catch (error) { + await pool.query('ROLLBACK'); console.error('Error deleting media:', error); res.status(500).json({ error: 'Failed to delete media' }); } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index e417764b..80a774c3 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,7 +5,9 @@ import App from './App'; import './App.css'; ReactDOM.createRoot(document.getElementById('root')).render( - - - + + + + + ); From abd65a536e5940565b305528b0f9a170a33576b7 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 01:14:48 -0400 Subject: [PATCH 6/7] fix: implement eventual consistency for media deletion Addresses Gatehouse advisory about image server deletion happening after DB commit. Now returns honest status when partial success occurs: - 200 OK: Both database and image server deletion succeeded - 202 Accepted: Database updated, image server cleanup pending Changes: - Track image server deletion success/failure - Return 202 with warning when image server delete fails - Log orphaned asset IDs for manual cleanup - Add TODO comment for background cleanup job This accepts eventual consistency as a design decision: - Database (source of truth) is always consistent - Image server failures don't block user operations - Orphaned images can be cleaned up later (manual or automated) - Clients are informed about partial success via 202 status --- backend/server.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index c37a079c..a9e7ffaa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1294,18 +1294,32 @@ app.delete('/api/pois/:poiId/media/:mediaId', isAuthenticated, async (req, res) await pool.query('COMMIT'); // Delete from image server (if it's an image/video, not YouTube) + // NOTE: Eventual consistency - DB transaction already committed + // If image server delete fails, orphaned assets should be cleaned up by background job + let imageServerDeleted = true; if (media.image_server_asset_id) { try { await imageServerClient.deleteAsset(media.image_server_asset_id); } catch (err) { console.error('Failed to delete asset from image server:', err); - // Continue anyway - DB record is already deleted + console.error('Orphaned asset:', media.image_server_asset_id); + imageServerDeleted = false; + // TODO: Implement background job to clean up orphaned assets } } // Invalidate mosaic cache invalidateMosaicCache(poiId); + // Return honest status about partial success + if (!imageServerDeleted) { + return res.status(202).json({ + success: true, + warning: 'Media deleted from database, image cleanup pending', + message: 'Media deleted' + }); + } + res.json({ success: true, message: 'Media deleted' }); } catch (error) { await pool.query('ROLLBACK'); From 166e579f2139c336330705ead6467bc83c40dcd5 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 5 Apr 2026 01:19:07 -0400 Subject: [PATCH 7/7] chore: replace TODO with issue reference for Gourmand compliance Replaced TODO comment with reference to issue #186 for background cleanup job. Gourmand doesn't allow TODOs - they must be either implemented immediately, tracked in a proper issue, or deleted. Since the background cleanup job is out of scope for this PR, created issue #186 to track it properly. --- backend/server.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/server.js b/backend/server.js index a9e7ffaa..f7c72db2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1295,16 +1295,15 @@ app.delete('/api/pois/:poiId/media/:mediaId', isAuthenticated, async (req, res) // Delete from image server (if it's an image/video, not YouTube) // NOTE: Eventual consistency - DB transaction already committed - // If image server delete fails, orphaned assets should be cleaned up by background job + // If image server delete fails, orphaned assets logged for cleanup (see #186) let imageServerDeleted = true; if (media.image_server_asset_id) { try { await imageServerClient.deleteAsset(media.image_server_asset_id); } catch (err) { console.error('Failed to delete asset from image server:', err); - console.error('Orphaned asset:', media.image_server_asset_id); + console.error('Orphaned asset (manual cleanup required):', media.image_server_asset_id); imageServerDeleted = false; - // TODO: Implement background job to clean up orphaned assets } }