diff --git a/backend/server.js b/backend/server.js index e57f0b1..fc7dba4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -20,10 +20,29 @@ const PORT = process.env.PORT || 3000; // Security middleware app.use(helmet()); -app.use(cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3005', + +// CORS configuration +const corsOptions = { + origin: function (origin, callback) { + const allowedOrigins = [ + process.env.FRONTEND_URL || 'http://localhost:3005', + process.env.ADMIN_URL || 'http://localhost:3005', + 'http://localhost:3002', // Vite dev server + 'http://localhost:3001', // For API testing + 'http://localhost:3000' // Direct backend access + ]; + + // Allow requests with no origin (like mobile apps or curl) + if (!origin || allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, credentials: true -})); +}; + +app.use(cors(corsOptions)); // Rate limiting const limiter = rateLimit({ diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7536436..276c270 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -6,11 +6,13 @@ const dashboardRoutes = require('./adminDashboard'); const archiveRoutes = require('./adminArchives'); const emailRoutes = require('./adminEmail'); const settingsRoutes = require('./adminSettings'); +const eventsRoutes = require('./adminEvents'); // Mount sub-routers router.use('/dashboard', dashboardRoutes); router.use('/archives', archiveRoutes); router.use('/email', emailRoutes); router.use('/settings', settingsRoutes); +router.use('/events', eventsRoutes); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js new file mode 100644 index 0000000..896409a --- /dev/null +++ b/backend/src/routes/adminEvents.js @@ -0,0 +1,295 @@ +const express = require('express'); +const { body, query, validationResult } = require('express-validator'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get all events with pagination and filters +router.get('/', adminAuth, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const offset = (page - 1) * limit; + const search = req.query.search || ''; + const status = req.query.status || 'all'; + const sortBy = req.query.sortBy || 'created_at'; + const sortOrder = req.query.sortOrder || 'desc'; + + // Build query + let query = db('events'); + + // Apply search filter + if (search) { + query = query.where((builder) => { + builder.where('event_name', 'like', `%${search}%`) + .orWhere('admin_email', 'like', `%${search}%`) + .orWhere('slug', 'like', `%${search}%`); + }); + } + + // Apply status filter + if (status === 'active') { + query = query.where('is_active', true).where('is_archived', false); + } else if (status === 'archived') { + query = query.where('is_archived', true); + } else if (status === 'inactive') { + query = query.where('is_active', false).where('is_archived', false); + } else if (status === 'expiring') { + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + query = query + .where('is_active', true) + .where('is_archived', false) + .where('expires_at', '<=', sevenDaysFromNow.toISOString()) + .where('expires_at', '>', new Date().toISOString()); + } + + // Get total count for pagination + const countQuery = query.clone(); + const [{ count }] = await countQuery.count('* as count'); + + // Apply sorting and pagination + const events = await query + .orderBy(sortBy, sortOrder) + .limit(limit) + .offset(offset); + + // Get photo counts for each event + const eventIds = events.map(e => e.id); + const photoCounts = await db('photos') + .whereIn('event_id', eventIds) + .groupBy('event_id') + .select('event_id') + .count('* as count'); + + // Map photo counts to events + const photoCountMap = photoCounts.reduce((acc, { event_id, count }) => { + acc[event_id] = parseInt(count); + return acc; + }, {}); + + // Add photo counts to events + const eventsWithCounts = events.map(event => ({ + ...event, + photo_count: photoCountMap[event.id] || 0 + })); + + res.json({ + events: eventsWithCounts, + pagination: { + page, + limit, + total: parseInt(count), + totalPages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Error fetching events:', error); + res.status(500).json({ error: 'Failed to fetch events' }); + } +}); + +// Get single event details +router.get('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events') + .where('id', id) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Get photo count + const [{ count: photoCount }] = await db('photos') + .where('event_id', id) + .count('* as count'); + + // Get total size + const [{ totalSize }] = await db('photos') + .where('event_id', id) + .sum('size_bytes as totalSize'); + + // Get recent photos + const recentPhotos = await db('photos') + .where('event_id', id) + .orderBy('uploaded_at', 'desc') + .limit(10) + .select('filename', 'type', 'size_bytes', 'uploaded_at'); + + res.json({ + ...event, + photo_count: parseInt(photoCount) || 0, + total_size: parseInt(totalSize) || 0, + recent_photos: recentPhotos + }); + } catch (error) { + console.error('Error fetching event:', error); + res.status(500).json({ error: 'Failed to fetch event details' }); + } +}); + +// Update event +router.put('/:id', adminAuth, [ + body('event_name').optional().trim().notEmpty(), + body('admin_email').optional().isEmail(), + body('is_active').optional().isBoolean(), + body('expires_at').optional().isISO8601(), + body('welcome_message').optional().trim(), + body('color_theme').optional().trim() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const updates = req.body; + + // Check if event exists + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Update event + await db('events') + .where('id', id) + .update({ + ...updates, + updated_at: new Date() + }); + + // Log activity + await db.logActivity({ + type: 'event_updated', + actorType: 'admin', + actorId: req.user.id, + actorName: req.user.username, + eventId: id, + eventName: event.event_name, + metadata: { changes: Object.keys(updates) } + }); + + res.json({ message: 'Event updated successfully' }); + } catch (error) { + console.error('Error updating event:', error); + res.status(500).json({ error: 'Failed to update event' }); + } +}); + +// Delete event +router.delete('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + // Check if event exists + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Delete associated photos + await db('photos').where('event_id', id).del(); + + // Delete event + await db('events').where('id', id).del(); + + // Log activity + await db.logActivity({ + type: 'event_deleted', + actorType: 'admin', + actorId: req.user.id, + actorName: req.user.username, + metadata: { event_name: event.event_name } + }); + + res.json({ message: 'Event deleted successfully' }); + } catch (error) { + console.error('Error deleting event:', error); + res.status(500).json({ error: 'Failed to delete event' }); + } +}); + +// Toggle event status +router.post('/:id/toggle-status', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const newStatus = !event.is_active; + await db('events') + .where('id', id) + .update({ + is_active: newStatus, + updated_at: new Date() + }); + + // Log activity + await db.logActivity({ + type: newStatus ? 'event_activated' : 'event_deactivated', + actorType: 'admin', + actorId: req.user.id, + actorName: req.user.username, + eventId: id, + eventName: event.event_name + }); + + res.json({ + message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`, + is_active: newStatus + }); + } catch (error) { + console.error('Error toggling event status:', error); + res.status(500).json({ error: 'Failed to toggle event status' }); + } +}); + +// Archive event +router.post('/:id/archive', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Event is already archived' }); + } + + await db('events') + .where('id', id) + .update({ + is_archived: true, + is_active: false, + archived_at: new Date(), + updated_at: new Date() + }); + + // Log activity + await db.logActivity({ + type: 'event_archived', + actorType: 'admin', + actorId: req.user.id, + actorName: req.user.username, + eventId: id, + eventName: event.event_name + }); + + res.json({ message: 'Event archived successfully' }); + } catch (error) { + console.error('Error archiving event:', error); + res.status(500).json({ error: 'Failed to archive event' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index a4f0a43..3c8e97b 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -227,6 +227,80 @@ router.put('/theme', adminAuth, async (req, res) => { } }); +// Update general settings +router.put('/general', adminAuth, async (req, res) => { + try { + const settings = req.body; + + // Update or insert each setting + for (const [key, value] of Object.entries(settings)) { + await db('app_settings') + .insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'general', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'general_settings_updated', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + metadata: JSON.stringify({ settings_count: Object.keys(settings).length }) + }); + + res.json({ message: 'General settings updated successfully' }); + } catch (error) { + console.error('General settings update error:', error); + res.status(500).json({ error: 'Failed to update general settings' }); + } +}); + +// Update security settings +router.put('/security', adminAuth, async (req, res) => { + try { + const settings = req.body; + + // Update or insert each setting + for (const [key, value] of Object.entries(settings)) { + await db('app_settings') + .insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'security', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'security_settings_updated', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + metadata: JSON.stringify({ settings_count: Object.keys(settings).length }) + }); + + res.json({ message: 'Security settings updated successfully' }); + } catch (error) { + console.error('Security settings update error:', error); + res.status(500).json({ error: 'Failed to update security settings' }); + } +}); + // Get storage info router.get('/storage/info', adminAuth, async (req, res) => { try { diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 282fdcb..7bd10ed 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -42,6 +42,10 @@ services: build: context: ./frontend dockerfile: Dockerfile.dev + args: + - VITE_API_URL=http://localhost:3001 + - VITE_UMAMI_URL= + - VITE_UMAMI_WEBSITE_ID= ports: - "3005:80" environment: @@ -65,6 +69,9 @@ services: - "3002:5173" environment: - NODE_ENV=development + - VITE_API_URL=http://localhost:3001 + - VITE_UMAMI_URL= + - VITE_UMAMI_WEBSITE_ID= volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/.env.example b/frontend/.env.example index 6ab3a91..b8f3c08 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,5 @@ # Backend API URL -VITE_API_URL=http://localhost:3001 +VITE_API_URL=http://localhost:3000 # Umami Analytics Configuration # Get these values from your Umami installation diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 307df31..b58da54 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -3,6 +3,16 @@ FROM node:18-alpine AS builder WORKDIR /app +# Accept build arguments +ARG VITE_API_URL +ARG VITE_UMAMI_URL +ARG VITE_UMAMI_WEBSITE_ID + +# Set environment variables for build +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_UMAMI_URL=$VITE_UMAMI_URL +ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID + # Copy package files COPY package*.json ./ diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 16a9039..83c6569 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -25,7 +25,7 @@ export const AdminLayout: React.FC = () => { } return ( -