diff --git a/CLAUDE.md b/CLAUDE.md index f878c85..4ead341 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment pm2 start ecosystem.config.js # Alternative: PM2 deployment ``` +**⚠️ CRITICAL PRODUCTION NOTICE:** +- Production runs on a SEPARATE SERVER - never assume local changes affect production +- ALWAYS request production server details before any troubleshooting +- NO trial-and-error approaches in production - data loss is unacceptable +- Every change must be thoroughly analyzed and tested locally first + ## Key Product Requirements (from PRD) ### Core Features @@ -127,6 +133,41 @@ Background services run as separate processes: 5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD 6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings +## Troubleshooting Guidelines + +### Before ANY Production Troubleshooting: +1. **ALWAYS request specific details**: + - Production server URL/IP + - Current error messages/logs + - Recent changes or deployments + - Affected users/galleries + - Time of issue occurrence + +2. **Thorough Analysis Required**: + - Use detailed thinking/analysis for EVERY troubleshooting task + - Review all related code before suggesting changes + - Consider all potential side effects + - Never make assumptions about production environment + +3. **Safe Troubleshooting Steps**: + - First, reproduce issue in local/dev environment + - Analyze logs without modifying production + - Create detailed action plan before any changes + - Always have rollback strategy ready + - Document every step taken + +### Common Issues & Safe Approaches: +- **Email not sending**: Check email_queue table, SMTP settings, service status +- **Photos not loading**: Verify file permissions, storage paths, nginx config +- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs +- **Performance problems**: Analyze with monitoring tools first, never experiment + +### Data Safety Rules: +- NEVER delete or modify production data without explicit backup confirmation +- ALWAYS verify backups exist before any data operations +- NO direct database modifications without transaction safety +- Log all actions for audit trail + ## Environment Variables ### Backend (.env) @@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme(); - Guest satisfaction: >90% - System uptime: 99.9% - Email delivery rate: >98% -- Successful archiving: 100% \ No newline at end of file +- Successful archiving: 100% + +## Documentation & Development Practices + +### Documentation Guidelines: +- **NEVER create new documentation files for simple tasks** +- **ALWAYS update existing documentation (like this CLAUDE.md)** +- Only create new .md files when explicitly requested +- Avoid creating temporary scripts for one-off tasks + +### Development Best Practices: +- Test all changes thoroughly in local environment first +- Use version control for all changes +- Keep commits atomic and well-described +- Review impact on all integrated services +- Consider backward compatibility +- Update tests when changing functionality + +### Production Deployment Checklist: +- [ ] All tests passing locally +- [ ] Linting and type checks pass +- [ ] Database migrations tested with rollback plan +- [ ] Environment variables documented +- [ ] Backup strategy confirmed +- [ ] Monitoring alerts configured +- [ ] Rollback procedure documented +- [ ] Stakeholders notified of maintenance window \ No newline at end of file diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 3817acf..1226087 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => { .count('id as count') .first(); - // Get total downloads (last 30 days) + // Get total downloads (last 30 days) - include both single and bulk downloads const totalDownloads = await db('access_logs') - .where('action', 'download') + .whereIn('action', ['download', 'download_all']) .where('timestamp', '>=', thirtyDaysAgo.toISOString()) .count('id as count') .first(); @@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => { .first(); const previousDownloads = await db('access_logs') - .where('action', 'download') + .whereIn('action', ['download', 'download_all']) .where('timestamp', '>=', sixtyDaysAgo.toISOString()) .where('timestamp', '<', thirtyDaysAgo.toISOString()) .count('id as count') @@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => { .where('timestamp', '>=', startDateStr) .groupByRaw('DATE(timestamp)'); - // Get downloads per day + // Get downloads per day - include both single and bulk downloads const downloadsData = await db('access_logs') .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count')) - .where('action', 'download') + .whereIn('action', ['download', 'download_all']) .where('timestamp', '>=', startDateStr) .groupByRaw('DATE(timestamp)'); @@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => { if (dateObj) dateObj.uniqueVisitors = row.count; }); - // Get top galleries by views + // Get top galleries by views with additional metrics const topGalleries = await db('access_logs') - .select('events.event_name', 'events.slug') - .select(db.raw('COUNT(*) as views')) + .select('events.id', 'events.event_name', 'events.slug') + .select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views')) + .select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors')) + .select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads')) .join('events', 'access_logs.event_id', 'events.id') - .where('access_logs.action', 'view') .where('access_logs.timestamp', '>=', startDateStr) - .groupBy('events.id') + .groupBy('events.id', 'events.event_name', 'events.slug') .orderBy('views', 'desc') .limit(5); diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index d278b74..8f13f2d 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -5,9 +5,9 @@ const router = express.Router(); // Get public settings (branding and theme) router.get('/', async (req, res) => { try { - // Fetch branding, theme, general, and select security settings + // Fetch branding, theme, general, security, and analytics settings const settings = await db('app_settings') - .whereIn('setting_type', ['branding', 'theme', 'general', 'security']) + .whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics']) .select('setting_key', 'setting_value'); // Convert to object format @@ -41,7 +41,12 @@ router.get('/', async (req, res) => { enable_analytics: settingsObject.general_enable_analytics !== false, enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true', recaptcha_site_key: settingsObject.security_recaptcha_site_key || null, - maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true' + maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true', + // Umami analytics configuration (only if enabled) + umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true', + umami_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_url || null) : null, + umami_website_id: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_website_id || null) : null, + umami_share_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_share_url || null) : null }; res.json(publicSettings); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd0f254..4e5fbdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,17 +44,26 @@ function App() { // Initialize Umami Analytics based on settings useEffect(() => { const initializeAnalytics = async () => { - const umamiUrl = import.meta.env.VITE_UMAMI_URL; - const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; - - if (umamiUrl && umamiWebsiteId) { - try { - // Fetch public settings to check if analytics is enabled - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - const settings = await response.json(); + try { + // Fetch public settings to get Umami configuration + const response = await fetch(`${getApiBaseUrl()}/public/settings`); + const settings = await response.json(); + + // Check if Umami is enabled and configured in backend settings + if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) { + // Use backend configuration + analyticsService.initialize({ + websiteId: settings.umami_website_id, + hostUrl: settings.umami_url, + autoTrack: true, + doNotTrack: true + }); + } else { + // Fall back to environment variables if backend not configured + const umamiUrl = import.meta.env.VITE_UMAMI_URL; + const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; - // Only initialize if analytics is enabled in settings - if (settings.enable_analytics !== false) { + if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) { analyticsService.initialize({ websiteId: umamiWebsiteId, hostUrl: umamiUrl, @@ -62,9 +71,14 @@ function App() { doNotTrack: true }); } - } catch (error) { - console.error('Failed to fetch settings for analytics:', error); - // Initialize analytics anyway if settings fetch fails + } + } catch (error) { + console.error('Failed to fetch settings for analytics:', error); + // Fall back to environment variables on error + const umamiUrl = import.meta.env.VITE_UMAMI_URL; + const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; + + if (umamiUrl && umamiWebsiteId) { analyticsService.initialize({ websiteId: umamiWebsiteId, hostUrl: umamiUrl, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f90defe..191f5b6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -494,6 +494,30 @@ "sent": "Sent", "failed": "Failed", "lastUpdate": "Last update" + }, + "analytics": { + "title": "Analytics", + "umamiIntegration": "Umami Analytics Integration", + "enableUmami": "Enable Umami Analytics", + "umamiUrl": "Umami URL", + "umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)", + "websiteId": "Website ID", + "websiteIdHelp": "Your Umami website ID (found in Umami dashboard)", + "shareUrl": "Share URL (Optional)", + "shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)", + "umamiInfo": "About Umami Analytics", + "umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.", + "learnMore": "Learn more about Umami", + "saveAnalyticsSettings": "Save Analytics Settings", + "backendAnalytics": "Backend Analytics", + "backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.", + "tracked": "Tracked Metrics", + "galleryViews": "Gallery page views", + "photoDownloads": "Individual and bulk downloads", + "uniqueVisitors": "Unique visitors by IP", + "deviceTypes": "Device types from user agents", + "privacy": "Privacy", + "privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days." } }, "analytics": { diff --git a/frontend/src/pages/admin/AnalyticsPage.tsx b/frontend/src/pages/admin/AnalyticsPage.tsx index 484b17d..a1964cd 100644 --- a/frontend/src/pages/admin/AnalyticsPage.tsx +++ b/frontend/src/pages/admin/AnalyticsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { BarChart3, TrendingUp, @@ -52,10 +52,8 @@ export const AnalyticsPage: React.FC = () => { const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d'); const [isEmbedMode, setIsEmbedMode] = useState(false); - // Check if Umami is configured - const umamiUrl = import.meta.env.VITE_UMAMI_URL; - // const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; - const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL; + // Check if Umami is configured from settings or environment + const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({}); // Fetch analytics data from backend const { data: apiData, isLoading, refetch } = useQuery({ @@ -73,6 +71,38 @@ export const AnalyticsPage: React.FC = () => { queryFn: () => adminService.getDashboardStats(), }); + // Fetch public settings to get Umami config + useEffect(() => { + const fetchUmamiConfig = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`); + const settings = await response.json(); + + if (settings.umami_enabled) { + setUmamiConfig({ + url: settings.umami_url, + shareUrl: settings.umami_share_url + }); + } else { + // Fall back to environment variables + setUmamiConfig({ + url: import.meta.env.VITE_UMAMI_URL, + shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL + }); + } + } catch (error) { + console.error('Failed to fetch Umami config:', error); + // Fall back to environment variables + setUmamiConfig({ + url: import.meta.env.VITE_UMAMI_URL, + shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL + }); + } + }; + + fetchUmamiConfig(); + }, []); + // Calculate trends and format data const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => { if (!apiData) return undefined; @@ -96,11 +126,15 @@ export const AnalyticsPage: React.FC = () => { const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0); const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0; - // Format top galleries for downloads - const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({ - name: gallery.event_name, - downloads: gallery.views // Using views as download count for now - })); + // Get actual download data for top galleries - sort by downloads + const topGalleriesWithDownloads = apiData.topGalleries + .filter(gallery => gallery.downloads > 0) // Only show galleries with downloads + .sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads + .slice(0, 5) // Take top 5 + .map(gallery => ({ + name: gallery.event_name, + downloads: gallery.downloads || 0 + })); return { pageViews: { @@ -122,7 +156,7 @@ export const AnalyticsPage: React.FC = () => { topPages: apiData.topGalleries.map(gallery => ({ path: `/gallery/${gallery.slug}`, views: gallery.views, - uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors + uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available })) }; }, [apiData]); @@ -166,7 +200,7 @@ export const AnalyticsPage: React.FC = () => { } // If Umami is configured and embed mode is enabled, show the Umami dashboard - if (isEmbedMode && umamiShareUrl) { + if (isEmbedMode && umamiConfig.shareUrl) { return (
{t('analytics.subtitle')}
+ {t('settings.analytics.umamiUrlHelp')} +
++ {t('settings.analytics.websiteIdHelp')} +
++ {t('settings.analytics.shareUrlHelp')} +
+{t('settings.analytics.umamiInfo')}
+{t('settings.analytics.umamiInfoText')}
+ + {t('settings.analytics.learnMore')} + +{t('settings.analytics.backendAnalyticsText')}
+ ++ {t('settings.analytics.privacyText')} +
+