dc1419c051
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>
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
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; |