feat: implement gallery feedback system with version tracking for backups
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:
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const feedbackService = require('./feedbackService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active');
|
||||
@@ -28,6 +29,37 @@ async function archiveEvent(event) {
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Export feedback data before archiving
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
if (feedbackSettings.feedback_enabled) {
|
||||
try {
|
||||
logger.info(`Exporting feedback data for event ${event.slug}`);
|
||||
const feedbackData = await feedbackService.exportEventFeedback(event.id);
|
||||
|
||||
if (feedbackData && feedbackData.length > 0) {
|
||||
// Create feedback JSON file
|
||||
const feedbackJson = JSON.stringify(feedbackData, null, 2);
|
||||
const feedbackJsonPath = path.join(eventPath, 'feedback_data.json');
|
||||
await fs.writeFile(feedbackJsonPath, feedbackJson, 'utf8');
|
||||
|
||||
// Create feedback CSV file
|
||||
const feedbackCsv = convertToCSV(feedbackData);
|
||||
const feedbackCsvPath = path.join(eventPath, 'feedback_data.csv');
|
||||
await fs.writeFile(feedbackCsvPath, feedbackCsv, 'utf8');
|
||||
|
||||
// Create feedback summary
|
||||
const summary = await feedbackService.getEventFeedbackSummary(event.id);
|
||||
const summaryPath = path.join(eventPath, 'feedback_summary.json');
|
||||
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
|
||||
|
||||
logger.info(`Feedback data exported: ${feedbackData.length} entries`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error exporting feedback for event ${event.slug}:`, error);
|
||||
// Continue with archiving even if feedback export fails
|
||||
}
|
||||
}
|
||||
|
||||
output.on('close', async () => {
|
||||
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
||||
|
||||
@@ -67,4 +99,25 @@ async function archiveEvent(event) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = { archiveEvent };
|
||||
|
||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const backupManifest = require('./backupManifest');
|
||||
const S3StorageAdapter = require('./storage/s3Storage');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Backup job reference
|
||||
let backupJob = null;
|
||||
@@ -20,6 +21,21 @@ let isRunning = false;
|
||||
// Storage paths
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Get current database schema version
|
||||
*/
|
||||
async function getCurrentSchemaVersion() {
|
||||
try {
|
||||
const result = await db('knex_migrations')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
return result ? result.name : 'unknown';
|
||||
} catch (error) {
|
||||
logger.error('Failed to get schema version:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file checksum using SHA256
|
||||
*/
|
||||
@@ -646,11 +662,17 @@ async function runBackup() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup run record
|
||||
// Get current schema version
|
||||
const schemaVersion = await getCurrentSchemaVersion();
|
||||
|
||||
// Create backup run record with version info
|
||||
const [runId] = await db('backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: 'scheduled'
|
||||
backup_type: 'scheduled',
|
||||
app_version: packageJson.version,
|
||||
node_version: process.version,
|
||||
db_schema_version: schemaVersion
|
||||
});
|
||||
|
||||
backupRun = { id: runId };
|
||||
@@ -801,7 +823,7 @@ async function runBackup() {
|
||||
// Don't fail the entire backup for manifest generation failure
|
||||
}
|
||||
|
||||
// Update backup run record
|
||||
// Update backup run record with manifest info
|
||||
await db('backup_runs')
|
||||
.where('id', runId)
|
||||
.update({
|
||||
@@ -812,6 +834,16 @@ async function runBackup() {
|
||||
duration_seconds: durationSeconds,
|
||||
manifest_path: manifestPath,
|
||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||
manifest_info: manifestSummary ? JSON.stringify({
|
||||
manifest_version: manifestSummary.manifest?.version,
|
||||
backup_id: manifestSummary.backup?.id,
|
||||
system_info: manifestSummary.system,
|
||||
file_count: manifestSummary.files?.count,
|
||||
database_info: {
|
||||
type: manifestSummary.database?.type,
|
||||
schema_version: manifestSummary.database?.schema_version
|
||||
}
|
||||
}) : null,
|
||||
statistics: JSON.stringify({
|
||||
totalFilesChecked: files.length,
|
||||
filesBackedUp: result.backedUpCount,
|
||||
|
||||
@@ -12,6 +12,7 @@ const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Constants
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||
@@ -312,12 +313,24 @@ class DatabaseBackupService {
|
||||
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
|
||||
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
|
||||
|
||||
// Create backup run record
|
||||
// Get current schema version
|
||||
const schemaVersion = await this.getCurrentSchemaVersion();
|
||||
|
||||
// Create backup run record with version info
|
||||
const [runId] = await db('database_backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: this.dbType,
|
||||
destination_path: finalFile
|
||||
destination_path: finalFile,
|
||||
app_version: packageJson.version,
|
||||
node_version: process.version,
|
||||
db_schema_version: schemaVersion,
|
||||
environment_info: JSON.stringify({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node_env: process.env.NODE_ENV || 'production',
|
||||
db_type: this.dbType
|
||||
})
|
||||
});
|
||||
|
||||
backupRun = { id: runId };
|
||||
@@ -383,7 +396,10 @@ class DatabaseBackupService {
|
||||
compressed: compress,
|
||||
validated: validateIntegrity,
|
||||
compressionStats,
|
||||
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null
|
||||
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null,
|
||||
app_version: packageJson.version,
|
||||
node_version: process.version,
|
||||
db_schema_version: await this.getCurrentSchemaVersion()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -560,11 +576,82 @@ class DatabaseBackupService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from backup (careful!)
|
||||
* Get current database schema version
|
||||
*/
|
||||
async getCurrentSchemaVersion() {
|
||||
try {
|
||||
const result = await db('knex_migrations')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
return result ? result.name : 'unknown';
|
||||
} catch (error) {
|
||||
logger.error('Failed to get schema version:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check version compatibility for restore
|
||||
*/
|
||||
async checkVersionCompatibility(backupInfo) {
|
||||
const currentAppVersion = packageJson.version;
|
||||
const currentNodeVersion = process.version;
|
||||
const currentSchemaVersion = await this.getCurrentSchemaVersion();
|
||||
|
||||
const compatibility = {
|
||||
compatible: true,
|
||||
warnings: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Check app version
|
||||
if (backupInfo.app_version !== currentAppVersion) {
|
||||
const backupMajor = backupInfo.app_version?.split('.')[0];
|
||||
const currentMajor = currentAppVersion.split('.')[0];
|
||||
|
||||
if (backupMajor !== currentMajor) {
|
||||
compatibility.errors.push(
|
||||
`Major version mismatch: backup v${backupInfo.app_version}, current v${currentAppVersion}`
|
||||
);
|
||||
compatibility.compatible = false;
|
||||
} else {
|
||||
compatibility.warnings.push(
|
||||
`Minor version difference: backup v${backupInfo.app_version}, current v${currentAppVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Node.js version
|
||||
if (backupInfo.node_version !== currentNodeVersion) {
|
||||
const backupNodeMajor = backupInfo.node_version?.split('.')[0];
|
||||
const currentNodeMajor = currentNodeVersion.split('.')[0];
|
||||
|
||||
if (backupNodeMajor !== currentNodeMajor) {
|
||||
compatibility.warnings.push(
|
||||
`Node.js major version difference: backup ${backupInfo.node_version}, current ${currentNodeVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check schema version
|
||||
if (backupInfo.db_schema_version && backupInfo.db_schema_version !== currentSchemaVersion) {
|
||||
compatibility.warnings.push(
|
||||
`Database schema difference: backup migration '${backupInfo.db_schema_version}', current '${currentSchemaVersion}'`
|
||||
);
|
||||
compatibility.warnings.push(
|
||||
'You may need to run migrations after restore'
|
||||
);
|
||||
}
|
||||
|
||||
return compatibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from backup (with version checking)
|
||||
*/
|
||||
async restore(backupPath, options = {}) {
|
||||
// This is a dangerous operation and should be used with extreme caution
|
||||
throw new Error('Restore functionality not implemented for safety. Please restore manually.');
|
||||
throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class FeedbackModerationService {
|
||||
constructor() {
|
||||
this.wordFiltersCache = null;
|
||||
this.cacheExpiry = null;
|
||||
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get word filters (with caching)
|
||||
*/
|
||||
async getWordFilters() {
|
||||
try {
|
||||
// Check cache
|
||||
if (this.wordFiltersCache && this.cacheExpiry && Date.now() < this.cacheExpiry) {
|
||||
return this.wordFiltersCache;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const filters = await db('feedback_word_filters')
|
||||
.where('is_active', true)
|
||||
.select('word', 'severity');
|
||||
|
||||
// Update cache
|
||||
this.wordFiltersCache = filters;
|
||||
this.cacheExpiry = Date.now() + this.CACHE_DURATION;
|
||||
|
||||
return filters;
|
||||
} catch (error) {
|
||||
logger.error('Error getting word filters:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear word filters cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.wordFiltersCache = null;
|
||||
this.cacheExpiry = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains inappropriate content
|
||||
*/
|
||||
async moderateText(text) {
|
||||
try {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { approved: true };
|
||||
}
|
||||
|
||||
const filters = await this.getWordFilters();
|
||||
const violations = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const filter of filters) {
|
||||
// Create regex for whole word matching
|
||||
const regex = new RegExp(`\\b${this.escapeRegex(filter.word.toLowerCase())}\\b`, 'gi');
|
||||
if (regex.test(lowerText)) {
|
||||
violations.push({
|
||||
word: filter.word,
|
||||
severity: filter.severity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for severe violations
|
||||
if (violations.some(v => v.severity === 'severe')) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Content contains prohibited words',
|
||||
violations: violations.filter(v => v.severity === 'severe')
|
||||
};
|
||||
}
|
||||
|
||||
// Check for moderate violations
|
||||
if (violations.some(v => v.severity === 'moderate')) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Content requires moderation',
|
||||
violations
|
||||
};
|
||||
}
|
||||
|
||||
// Check for mild violations (may just flag for review)
|
||||
if (violations.length > 0) {
|
||||
return {
|
||||
approved: true,
|
||||
flagged: true,
|
||||
reason: 'Content contains potentially inappropriate words',
|
||||
violations
|
||||
};
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
const additionalChecks = this.performAdditionalChecks(text);
|
||||
if (!additionalChecks.passed) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: additionalChecks.reason
|
||||
};
|
||||
}
|
||||
|
||||
return { approved: true };
|
||||
} catch (error) {
|
||||
logger.error('Error moderating text:', error);
|
||||
// In case of error, err on the side of caution
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Moderation system error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform additional content checks
|
||||
*/
|
||||
performAdditionalChecks(text) {
|
||||
// Check for excessive caps
|
||||
const capsRatio = (text.match(/[A-Z]/g) || []).length / text.length;
|
||||
if (text.length > 10 && capsRatio > 0.7) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: 'Excessive use of capital letters'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for spam patterns
|
||||
if (this.detectSpamPatterns(text)) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: 'Content appears to be spam'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for excessive special characters
|
||||
const specialCharRatio = (text.match(/[!@#$%^&*()]/g) || []).length / text.length;
|
||||
if (text.length > 10 && specialCharRatio > 0.3) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: 'Excessive use of special characters'
|
||||
};
|
||||
}
|
||||
|
||||
return { passed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect common spam patterns
|
||||
*/
|
||||
detectSpamPatterns(text) {
|
||||
const spamPatterns = [
|
||||
/\b(buy|cheap|discount|offer|sale|deal)\s+(now|today|here)/gi,
|
||||
/\b(click|visit|check)\s+(here|link|this)/gi,
|
||||
/\b(viagra|cialis|pills|drugs)\b/gi,
|
||||
/\b(casino|betting|poker|slots)\b/gi,
|
||||
/\b(make|earn)\s+\$?\d+/gi,
|
||||
/https?:\/\/[^\s]+/gi, // URLs (might want to allow in some cases)
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses
|
||||
/\b\d{3,}\s?\d{3,}\s?\d{4,}\b/g // Phone numbers
|
||||
];
|
||||
|
||||
return spamPatterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters
|
||||
*/
|
||||
escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add word filter
|
||||
*/
|
||||
async addWordFilter(word, severity = 'moderate') {
|
||||
try {
|
||||
await db('feedback_word_filters').insert({
|
||||
word: word.toLowerCase(),
|
||||
severity,
|
||||
is_active: true,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
this.clearCache();
|
||||
logger.info(`Added word filter: ${word} (${severity})`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT' || error.code === '23505') {
|
||||
throw new Error('Word filter already exists');
|
||||
}
|
||||
logger.error('Error adding word filter:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update word filter
|
||||
*/
|
||||
async updateWordFilter(id, updates) {
|
||||
try {
|
||||
await db('feedback_word_filters')
|
||||
.where('id', id)
|
||||
.update(updates);
|
||||
|
||||
this.clearCache();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error updating word filter:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete word filter
|
||||
*/
|
||||
async deleteWordFilter(id) {
|
||||
try {
|
||||
await db('feedback_word_filters')
|
||||
.where('id', id)
|
||||
.delete();
|
||||
|
||||
this.clearCache();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error deleting word filter:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all word filters (for admin)
|
||||
*/
|
||||
async getAllWordFilters() {
|
||||
try {
|
||||
return await db('feedback_word_filters')
|
||||
.orderBy('severity', 'desc')
|
||||
.orderBy('word', 'asc');
|
||||
} catch (error) {
|
||||
logger.error('Error getting all word filters:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text for display (remove but don't reject)
|
||||
*/
|
||||
sanitizeText(text) {
|
||||
// Remove excessive whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Remove zero-width characters
|
||||
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||
|
||||
// Limit consecutive special characters
|
||||
text = text.replace(/([!?.]){3,}/g, '$1$1');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user should be rate limited based on previous violations
|
||||
*/
|
||||
async checkUserReputation(guestIdentifier, eventId) {
|
||||
try {
|
||||
// Count recent violations
|
||||
const recentViolations = await db('photo_feedback')
|
||||
.where('guest_identifier', guestIdentifier)
|
||||
.where('event_id', eventId)
|
||||
.where('is_hidden', true)
|
||||
.where('created_at', '>', new Date(Date.now() - 24 * 60 * 60 * 1000)) // Last 24 hours
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// If user has multiple violations, they might be problematic
|
||||
if (recentViolations && recentViolations.count > 3) {
|
||||
return {
|
||||
trusted: false,
|
||||
reason: 'Multiple recent violations'
|
||||
};
|
||||
}
|
||||
|
||||
// Check total approved comments
|
||||
const approvedComments = await db('photo_feedback')
|
||||
.where('guest_identifier', guestIdentifier)
|
||||
.where('event_id', eventId)
|
||||
.where('feedback_type', 'comment')
|
||||
.where('is_approved', true)
|
||||
.where('is_hidden', false)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// User with many approved comments is trusted
|
||||
if (approvedComments && approvedComments.count > 10) {
|
||||
return {
|
||||
trusted: true,
|
||||
autoApprove: true
|
||||
};
|
||||
}
|
||||
|
||||
return { trusted: true };
|
||||
} catch (error) {
|
||||
logger.error('Error checking user reputation:', error);
|
||||
return { trusted: true }; // Default to trusting in case of error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackModerationService();
|
||||
@@ -0,0 +1,393 @@
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
class FeedbackService {
|
||||
/**
|
||||
* Get feedback settings for an event
|
||||
*/
|
||||
async getEventFeedbackSettings(eventId) {
|
||||
try {
|
||||
const settings = await db('event_feedback_settings')
|
||||
.where('event_id', eventId)
|
||||
.first();
|
||||
|
||||
if (!settings) {
|
||||
// Return default settings if none exist
|
||||
return {
|
||||
event_id: eventId,
|
||||
feedback_enabled: false,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: false,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true
|
||||
};
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update feedback settings for an event
|
||||
*/
|
||||
async updateEventFeedbackSettings(eventId, settings) {
|
||||
try {
|
||||
const existing = await db('event_feedback_settings')
|
||||
.where('event_id', eventId)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db('event_feedback_settings')
|
||||
.where('event_id', eventId)
|
||||
.update({
|
||||
...settings,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} else {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
...settings,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('feedback_settings_updated', settings, eventId);
|
||||
|
||||
return this.getEventFeedbackSettings(eventId);
|
||||
} catch (error) {
|
||||
logger.error('Error updating feedback settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a photo
|
||||
*/
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
// Check if similar feedback already exists (prevent duplicates)
|
||||
if (feedback_type !== 'comment') {
|
||||
const existing = await db('photo_feedback')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
guest_identifier: guestIdentifier
|
||||
})
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
if (feedback_type === 'rating' && rating !== existing.rating) {
|
||||
// Update existing rating
|
||||
await db('photo_feedback')
|
||||
.where('id', existing.id)
|
||||
.update({
|
||||
rating,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { id: existing.id, updated: true };
|
||||
}
|
||||
|
||||
// For likes and favorites, toggle off if already exists
|
||||
if (feedback_type === 'like' || feedback_type === 'favorite') {
|
||||
await db('photo_feedback')
|
||||
.where('id', existing.id)
|
||||
.delete();
|
||||
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
return { id: existing.id, exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new feedback
|
||||
const [id] = await db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
rating: feedback_type === 'rating' ? rating : null,
|
||||
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
ip_address,
|
||||
user_agent,
|
||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Update photo stats
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
// Log activity
|
||||
await logActivity(`photo_${feedback_type}`, { photo_id: photoId }, eventId);
|
||||
|
||||
return { id, created: true };
|
||||
} catch (error) {
|
||||
logger.error('Error submitting feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback for a photo
|
||||
*/
|
||||
async getPhotoFeedback(photoId, options = {}) {
|
||||
try {
|
||||
const query = db('photo_feedback')
|
||||
.where('photo_id', photoId);
|
||||
|
||||
if (options.feedback_type) {
|
||||
query.where('feedback_type', options.feedback_type);
|
||||
}
|
||||
|
||||
if (options.approved_only) {
|
||||
query.where('is_approved', true);
|
||||
}
|
||||
|
||||
if (!options.include_hidden) {
|
||||
query.where('is_hidden', false);
|
||||
}
|
||||
|
||||
if (options.guest_identifier) {
|
||||
query.where('guest_identifier', options.guest_identifier);
|
||||
}
|
||||
|
||||
const feedback = await query
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at');
|
||||
|
||||
return feedback;
|
||||
} catch (error) {
|
||||
logger.error('Error getting photo feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback summary for an event
|
||||
*/
|
||||
async getEventFeedbackSummary(eventId) {
|
||||
try {
|
||||
const photos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count')
|
||||
.orderBy('average_rating', 'desc')
|
||||
.orderBy('like_count', 'desc');
|
||||
|
||||
const totalStats = await db('photo_feedback')
|
||||
.where('event_id', eventId)
|
||||
.select(
|
||||
db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite'])
|
||||
)
|
||||
.first();
|
||||
|
||||
return {
|
||||
photos,
|
||||
stats: totalStats
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback summary:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update photo feedback statistics
|
||||
*/
|
||||
async updatePhotoFeedbackStats(photoId) {
|
||||
try {
|
||||
// Get aggregated stats
|
||||
const stats = await db('photo_feedback')
|
||||
.where('photo_id', photoId)
|
||||
.where('is_hidden', false)
|
||||
.select(
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
|
||||
)
|
||||
.first();
|
||||
|
||||
// Update photo table
|
||||
await db('photos')
|
||||
.where('id', photoId)
|
||||
.update({
|
||||
feedback_count: stats.feedback_count || 0,
|
||||
like_count: stats.like_count || 0,
|
||||
average_rating: stats.average_rating || 0,
|
||||
favorite_count: stats.favorite_count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo feedback stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderate feedback (approve/hide)
|
||||
*/
|
||||
async moderateFeedback(feedbackId, action, adminId) {
|
||||
try {
|
||||
const updates = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
if (action === 'approve') {
|
||||
updates.is_approved = true;
|
||||
updates.is_hidden = false;
|
||||
} else if (action === 'hide') {
|
||||
updates.is_hidden = true;
|
||||
} else if (action === 'reject') {
|
||||
updates.is_approved = false;
|
||||
updates.is_hidden = true;
|
||||
}
|
||||
|
||||
const feedback = await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.first();
|
||||
|
||||
if (!feedback) {
|
||||
throw new Error('Feedback not found');
|
||||
}
|
||||
|
||||
await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.update(updates);
|
||||
|
||||
// Update photo stats if visibility changed
|
||||
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||
|
||||
// Log moderation action
|
||||
await logActivity('feedback_moderated', {
|
||||
feedback_id: feedbackId,
|
||||
action,
|
||||
admin_id: adminId
|
||||
}, feedback.event_id);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error moderating feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete feedback
|
||||
*/
|
||||
async deleteFeedback(feedbackId, adminId) {
|
||||
try {
|
||||
const feedback = await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.first();
|
||||
|
||||
if (!feedback) {
|
||||
throw new Error('Feedback not found');
|
||||
}
|
||||
|
||||
await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.delete();
|
||||
|
||||
// Update photo stats
|
||||
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||
|
||||
// Log deletion
|
||||
await logActivity('feedback_deleted', {
|
||||
feedback_id: feedbackId,
|
||||
feedback_type: feedback.feedback_type,
|
||||
admin_id: adminId
|
||||
}, feedback.event_id);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error deleting feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback requiring moderation
|
||||
*/
|
||||
async getPendingModeration(eventId = null) {
|
||||
try {
|
||||
let query = db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.join('events', 'photo_feedback.event_id', 'events.id')
|
||||
.where('photo_feedback.is_approved', false)
|
||||
.where('photo_feedback.is_hidden', false)
|
||||
.where('photo_feedback.feedback_type', 'comment');
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photo_feedback.event_id', eventId);
|
||||
}
|
||||
|
||||
const pending = await query
|
||||
.select(
|
||||
'photo_feedback.*',
|
||||
'photos.filename as photo_filename',
|
||||
'events.event_name'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
return pending;
|
||||
} catch (error) {
|
||||
logger.error('Error getting pending moderation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export feedback data for an event
|
||||
*/
|
||||
async exportEventFeedback(eventId) {
|
||||
try {
|
||||
const feedback = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.event_id', eventId)
|
||||
.select(
|
||||
'photos.filename',
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.created_at'
|
||||
)
|
||||
.orderBy('photos.filename')
|
||||
.orderBy('photo_feedback.created_at');
|
||||
|
||||
return feedback;
|
||||
} catch (error) {
|
||||
logger.error('Error exporting feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackService();
|
||||
Reference in New Issue
Block a user