feat: implement gallery feedback system with version tracking for backups
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Successful in 3s

Gallery Feedback Features:
- Add feedback system allowing ratings, likes, comments, and favorites on photos
- Implement admin controls for enabling/disabling feedback per event
- Add content moderation with word filters and spam detection
- Implement rate limiting to prevent abuse (10 requests/15min per type)
- Create comprehensive admin interface for feedback management
- Add analytics dashboard for feedback insights
- Export feedback data when archiving events

Frontend Components:
- PhotoRating: 5-star rating system with optimistic updates
- PhotoLikes: Like/unlike with animation
- PhotoComments: Threaded comments with moderation
- PhotoFavorites: Bookmark functionality
- FeedbackSettings: Admin configuration panel
- EventFeedbackPage: Complete management interface

Backend Implementation:
- Database migration 033: 4 new tables for feedback system
- RESTful API with proper authorization
- Guest identification via SHA256(IP+UserAgent)
- Automatic backup integration
- Email notification support

Backup Version Tracking:
- Migration 034: Add version columns to backup tables
- Track app version, Node.js version, and DB schema version
- Create restore_history table for tracking restore attempts
- Add version compatibility checking for safe restores
- Configurable version matching requirements

Security & Performance:
- Input validation and sanitization
- Rate limiting per feedback type
- Content moderation system
- Optimistic UI updates
- Efficient database queries with proper indexes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-22 15:08:52 +02:00
parent 60fdd5d6ef
commit b31f7e6f34
28 changed files with 4312 additions and 15 deletions
+383
View File
@@ -0,0 +1,383 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const {
validateEventId,
validateFeedbackSettings,
validateWordFilter,
checkValidation
} = require('../utils/feedbackValidation');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
// Verify event exists and belongs to admin
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const settings = await feedbackService.getEventFeedbackSettings(eventId);
res.json(settings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
res.status(500).json({ error: 'Failed to get feedback settings' });
}
}
);
// Update event feedback settings
router.put('/events/:eventId/feedback-settings',
adminAuth,
validateEventId,
validateFeedbackSettings,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const settings = req.body;
// Verify event exists
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const updatedSettings = await feedbackService.updateEventFeedbackSettings(eventId, settings);
await logActivity('feedback_settings_updated', {
event_id: eventId,
settings: updatedSettings
}, eventId, {
type: 'admin',
id: req.user.id,
name: req.user.username
});
res.json(updatedSettings);
} catch (error) {
logger.error('Error updating feedback settings:', error);
res.status(500).json({ error: 'Failed to update feedback settings' });
}
}
);
// Get feedback for an event (with filters)
router.get('/events/:eventId/feedback',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const { type, status, photoId, page = 1, limit = 50 } = req.query;
// Build query
let query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', eventId)
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
);
if (type) {
query = query.where('photo_feedback.feedback_type', type);
}
if (status === 'pending') {
query = query.where('photo_feedback.is_approved', false)
.where('photo_feedback.is_hidden', false);
} else if (status === 'approved') {
query = query.where('photo_feedback.is_approved', true);
} else if (status === 'hidden') {
query = query.where('photo_feedback.is_hidden', true);
}
if (photoId) {
query = query.where('photo_feedback.photo_id', photoId);
}
// Pagination
const offset = (page - 1) * limit;
const totalCount = await query.clone().count('photo_feedback.id as count').first();
const feedback = await query
.orderBy('photo_feedback.created_at', 'desc')
.limit(limit)
.offset(offset);
res.json({
feedback,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount.count || 0,
pages: Math.ceil((totalCount.count || 0) / limit)
}
});
} catch (error) {
logger.error('Error getting feedback:', error);
res.status(500).json({ error: 'Failed to get feedback' });
}
}
);
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
async (req, res) => {
try {
const { feedbackId, action } = req.params;
if (!['approve', 'hide', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
res.json({ success: true });
} catch (error) {
logger.error('Error moderating feedback:', error);
res.status(500).json({ error: 'Failed to moderate feedback' });
}
}
);
// Delete feedback
router.delete('/feedback/:feedbackId',
adminAuth,
async (req, res) => {
try {
const { feedbackId } = req.params;
await feedbackService.deleteFeedback(feedbackId, req.user.id);
res.json({ success: true });
} catch (error) {
logger.error('Error deleting feedback:', error);
res.status(500).json({ error: 'Failed to delete feedback' });
}
}
);
// Get feedback analytics for an event
router.get('/events/:eventId/feedback-analytics',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
// Get summary statistics
const summary = await feedbackService.getEventFeedbackSummary(eventId);
// Get top-rated photos
const topRated = await db('photos')
.where('event_id', eventId)
.where('average_rating', '>', 0)
.orderBy('average_rating', 'desc')
.orderBy('feedback_count', 'desc')
.limit(10)
.select('id', 'filename', 'average_rating', 'feedback_count', 'like_count');
// Get most liked photos
const mostLiked = await db('photos')
.where('event_id', eventId)
.where('like_count', '>', 0)
.orderBy('like_count', 'desc')
.limit(10)
.select('id', 'filename', 'like_count', 'average_rating');
// Get recent comments
const recentComments = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', eventId)
.where('photo_feedback.feedback_type', 'comment')
.where('photo_feedback.is_approved', true)
.where('photo_feedback.is_hidden', false)
.orderBy('photo_feedback.created_at', 'desc')
.limit(10)
.select(
'photo_feedback.comment_text',
'photo_feedback.guest_name',
'photo_feedback.created_at',
'photos.filename'
);
// Get feedback timeline (last 7 days)
const timeline = await db('photo_feedback')
.where('event_id', eventId)
.where('created_at', '>', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.select(
db.raw('DATE(created_at) as date'),
db.raw('COUNT(*) as count'),
'feedback_type'
)
.groupBy('date', 'feedback_type')
.orderBy('date', 'asc');
res.json({
summary,
topRated,
mostLiked,
recentComments,
timeline
});
} catch (error) {
logger.error('Error getting feedback analytics:', error);
res.status(500).json({ error: 'Failed to get feedback analytics' });
}
}
);
// Export feedback data
router.get('/events/:eventId/feedback/export',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const { format = 'json' } = req.query;
const feedback = await feedbackService.exportEventFeedback(eventId);
if (format === 'csv') {
// Convert to CSV
const csv = convertToCSV(feedback);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`);
res.send(csv);
} else {
res.json(feedback);
}
} catch (error) {
logger.error('Error exporting feedback:', error);
res.status(500).json({ error: 'Failed to export feedback' });
}
}
);
// Get pending moderation items (across all events)
router.get('/feedback/pending-moderation',
adminAuth,
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
res.json(pending);
} catch (error) {
logger.error('Error getting pending moderation:', error);
res.status(500).json({ error: 'Failed to get pending moderation' });
}
}
);
// Word filter management
router.get('/feedback/word-filters',
adminAuth,
async (req, res) => {
try {
const filters = await feedbackModeration.getAllWordFilters();
res.json(filters);
} catch (error) {
logger.error('Error getting word filters:', error);
res.status(500).json({ error: 'Failed to get word filters' });
}
}
);
router.post('/feedback/word-filters',
adminAuth,
validateWordFilter,
checkValidation,
async (req, res) => {
try {
const { word, severity = 'moderate' } = req.body;
await feedbackModeration.addWordFilter(word, severity);
await logActivity('word_filter_added', { word, severity }, null, {
type: 'admin',
id: req.user.id,
name: req.user.username
});
res.json({ success: true });
} catch (error) {
if (error.message === 'Word filter already exists') {
return res.status(409).json({ error: error.message });
}
logger.error('Error adding word filter:', error);
res.status(500).json({ error: 'Failed to add word filter' });
}
}
);
router.put('/feedback/word-filters/:id',
adminAuth,
async (req, res) => {
try {
const { id } = req.params;
const updates = req.body;
await feedbackModeration.updateWordFilter(id, updates);
res.json({ success: true });
} catch (error) {
logger.error('Error updating word filter:', error);
res.status(500).json({ error: 'Failed to update word filter' });
}
}
);
router.delete('/feedback/word-filters/:id',
adminAuth,
async (req, res) => {
try {
const { id } = req.params;
await feedbackModeration.deleteWordFilter(id);
res.json({ success: true });
} catch (error) {
logger.error('Error deleting word filter:', error);
res.status(500).json({ error: 'Failed to delete word filter' });
}
}
);
// Helper function to convert JSON to CSV
function convertToCSV(data) {
if (!data || data.length === 0) return '';
const headers = Object.keys(data[0]);
const csvHeaders = headers.join(',');
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
}).join(',');
});
return [csvHeaders, ...csvRows].join('\n');
}
module.exports = router;
+342
View File
@@ -0,0 +1,342 @@
const express = require('express');
const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const {
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
validateGuestRequirements
} = require('../utils/feedbackValidation');
const { escapeLikePattern } = require('../utils/sqlSecurity');
// Get feedback settings for a gallery
router.get('/:slug/feedback-settings',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
const guestSettings = {
feedback_enabled: settings.feedback_enabled,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
require_name_email: settings.require_name_email,
show_feedback_to_guests: settings.show_feedback_to_guests
};
res.json(guestSettings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
res.status(500).json({ error: 'Failed to get feedback settings' });
}
}
);
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
validatePhotoId,
checkValidation,
async (req, res) => {
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get feedback based on settings
const options = {
approved_only: true,
include_hidden: false
};
// Include guest's own feedback even if not approved
const feedback = await feedbackService.getPhotoFeedback(photoId, options);
// Get guest's own feedback separately
const guestFeedback = await feedbackService.getPhotoFeedback(photoId, {
guest_identifier: guestIdentifier
});
// Combine and deduplicate
const allFeedback = [...feedback];
guestFeedback.forEach(gf => {
if (!feedback.find(f => f.id === gf.id)) {
allFeedback.push({ ...gf, is_mine: true });
} else {
const index = allFeedback.findIndex(f => f.id === gf.id);
allFeedback[index].is_mine = true;
}
});
// Filter based on what guests should see
const visibleFeedback = settings.show_feedback_to_guests ? allFeedback :
allFeedback.filter(f => f.is_mine);
res.json({
feedback: visibleFeedback,
summary: {
average_rating: photo.average_rating || 0,
total_ratings: await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'rating', is_hidden: false })
.count('id as count')
.first()
.then(r => r.count),
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0,
comment_count: await db('photo_feedback')
.where({
photo_id: photoId,
feedback_type: 'comment',
is_approved: true,
is_hidden: false
})
.count('id as count')
.first()
.then(r => r.count)
},
my_feedback: {
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite')
}
});
} catch (error) {
logger.error('Error getting photo feedback:', error);
res.status(500).json({ error: 'Failed to get feedback' });
}
}
);
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
async (req, res) => {
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// Check if specific feedback type is allowed
const feedbackType = req.body.feedback_type;
const typeAllowed = {
rating: settings.allow_ratings,
like: settings.allow_likes,
comment: settings.allow_comments,
favorite: settings.allow_favorites
};
if (!typeAllowed[feedbackType]) {
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Validate guest requirements
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
}
// Apply rate limiting based on feedback type
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
await new Promise((resolve, reject) => {
rateLimitMiddleware(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
// If we got here and response was sent (rate limited), return
if (res.headersSent) return;
// Prepare feedback data
const feedbackData = {
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent'],
moderate_comments: settings.moderate_comments
};
// For comments, check moderation
if (feedbackType === 'comment') {
// Check user reputation
const reputation = await feedbackModeration.checkUserReputation(guestIdentifier, event.id);
// Moderate the comment
const moderationResult = await feedbackModeration.moderateText(req.body.comment_text);
if (!moderationResult.approved) {
// Still save but mark as not approved
feedbackData.is_approved = false;
logger.warn('Comment flagged for moderation:', {
reason: moderationResult.reason,
violations: moderationResult.violations
});
} else if (reputation.autoApprove) {
// Trusted user, auto-approve
feedbackData.is_approved = true;
} else if (settings.moderate_comments) {
// Default moderation setting
feedbackData.is_approved = false;
}
}
// Submit feedback
const result = await feedbackService.submitFeedback(
photoId,
event.id,
feedbackData,
guestIdentifier
);
// Log activity
await logActivity(`guest_feedback_${feedbackType}`, {
photo_id: photoId,
result
}, event.id, {
type: 'guest',
id: guestIdentifier.substring(0, 16),
name: req.body.guest_name || 'Anonymous'
});
res.json({
success: true,
...result,
message: feedbackType === 'comment' && !feedbackData.is_approved ?
'Your comment has been submitted for moderation' : undefined
});
} catch (error) {
logger.error('Error submitting feedback:', error);
res.status(500).json({ error: 'Failed to submit feedback' });
}
}
);
// Get feedback summary for entire gallery
router.get('/:slug/feedback-summary',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled || !settings.show_feedback_to_guests) {
return res.json({
enabled: false,
summary: null
});
}
const summary = await feedbackService.getEventFeedbackSummary(event.id);
// Filter data based on what guests should see
const guestSummary = {
stats: summary.stats,
top_rated: summary.photos
.filter(p => p.average_rating > 0)
.slice(0, 5)
.map(p => ({
id: p.id,
filename: p.filename,
average_rating: p.average_rating,
like_count: p.like_count
}))
};
res.json({
enabled: true,
settings: {
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites
},
summary: guestSummary
});
} catch (error) {
logger.error('Error getting feedback summary:', error);
res.status(500).json({ error: 'Failed to get feedback summary' });
}
}
);
// Get user's own feedback for all photos
router.get('/:slug/my-feedback',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
const myFeedback = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id)
.where('photo_feedback.guest_identifier', guestIdentifier)
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
)
.orderBy('photo_feedback.created_at', 'desc');
res.json(myFeedback);
} catch (error) {
logger.error('Error getting user feedback:', error);
res.status(500).json({ error: 'Failed to get your feedback' });
}
}
);
module.exports = router;