Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18ddca6c27 | |||
| b31f7e6f34 | |||
| 60fdd5d6ef | |||
| 0ee7eeaa59 | |||
| 811230fb30 | |||
| 761b7791f9 | |||
| 41e89676c1 | |||
| 484ac9a404 | |||
| c1068bbc51 | |||
| b6fb712c9f |
+37
-3
@@ -114,12 +114,46 @@ steps:
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITHUB_TOKEN
|
||||
files: ["CHANGELOG.md"]
|
||||
base_url: https://api.github.com/
|
||||
upload_url: https://uploads.github.com/
|
||||
repo: the-luap/picpeak
|
||||
owner: the-luap
|
||||
title: "PicPeak ${DRONE_TAG}"
|
||||
note: |
|
||||
Release ${DRONE_TAG}
|
||||
# PicPeak ${DRONE_TAG}
|
||||
|
||||
See CHANGELOG.md for full details.
|
||||
## 🐳 Docker Images
|
||||
|
||||
This release includes Docker images published to GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:latest
|
||||
|
||||
# Frontend
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:latest
|
||||
```
|
||||
|
||||
## 📦 What's New
|
||||
|
||||
See the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Use the tagged version
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).
|
||||
|
||||
trigger:
|
||||
event:
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Backup Version Tracking Implementation
|
||||
|
||||
## Overview
|
||||
Version tracking has been added to the backup system to ensure safe restoration by tracking application versions, Node.js versions, and database schema versions at the time of backup.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Changes (Migration 034)
|
||||
|
||||
Added version tracking columns to backup tables:
|
||||
|
||||
#### `database_backup_runs` table:
|
||||
- `app_version` - Application version from package.json
|
||||
- `node_version` - Node.js runtime version
|
||||
- `db_schema_version` - Latest migration name
|
||||
- `environment_info` - JSON with additional environment details
|
||||
|
||||
#### `backup_runs` table:
|
||||
- `app_version` - Application version
|
||||
- `node_version` - Node.js version
|
||||
- `db_schema_version` - Database schema version
|
||||
- `manifest_info` - Summary of manifest information
|
||||
|
||||
#### New `restore_history` table:
|
||||
Tracks all restore attempts with comprehensive version information:
|
||||
- Backup versions vs current versions
|
||||
- Compatibility check results
|
||||
- Warnings and errors
|
||||
- Restore outcome
|
||||
|
||||
### 2. Version Information Captured
|
||||
|
||||
During each backup, the system now records:
|
||||
- **Application Version**: From `package.json` (e.g., "1.0.77")
|
||||
- **Node.js Version**: Runtime version (e.g., "v18.17.0")
|
||||
- **Database Schema**: Latest migration file (e.g., "034_add_version_to_backups.js")
|
||||
- **Environment Info**: Platform, architecture, environment mode
|
||||
|
||||
### 3. Backup Services Updated
|
||||
|
||||
#### Database Backup Service (`databaseBackup.js`):
|
||||
- Records version info when creating backups
|
||||
- Includes versions in statistics JSON
|
||||
- New method: `checkVersionCompatibility()` for restore safety
|
||||
- New method: `getCurrentSchemaVersion()` to track migrations
|
||||
|
||||
#### File Backup Service (`backupService.js`):
|
||||
- Records version info in backup_runs table
|
||||
- Integrates with manifest system
|
||||
- Stores manifest summary with version details
|
||||
|
||||
### 4. Existing Manifest System
|
||||
|
||||
The `backupManifest.js` already provides comprehensive version tracking:
|
||||
- Application version and Node.js version
|
||||
- System information (OS, platform, architecture)
|
||||
- Database schema version
|
||||
- Detailed file and database metadata
|
||||
|
||||
### 5. Version Compatibility Checking
|
||||
|
||||
When restoring, the system can now:
|
||||
- Compare backup version vs current version
|
||||
- Detect major/minor version differences
|
||||
- Identify schema mismatches
|
||||
- Provide warnings and recommendations
|
||||
|
||||
### 6. Configuration Settings
|
||||
|
||||
New backup settings for version control:
|
||||
- `backup_require_version_match` - Enforce exact version matching
|
||||
- `backup_allow_minor_version_mismatch` - Allow same major version
|
||||
- `backup_warn_on_version_mismatch` - Show warnings on mismatch
|
||||
- `backup_check_schema_compatibility` - Validate schema versions
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Backups
|
||||
Backups automatically capture version information - no changes needed to existing backup workflows.
|
||||
|
||||
### Checking Version Before Restore
|
||||
|
||||
1. **For Database Backups**:
|
||||
```javascript
|
||||
const compatibility = await databaseBackupService.checkVersionCompatibility({
|
||||
app_version: '1.0.75',
|
||||
node_version: 'v16.14.0',
|
||||
db_schema_version: '032_add_feedback.js'
|
||||
});
|
||||
|
||||
if (!compatibility.compatible) {
|
||||
console.error('Version mismatch:', compatibility.errors);
|
||||
}
|
||||
```
|
||||
|
||||
2. **For File Backups**:
|
||||
Check the manifest file which contains all version information:
|
||||
```bash
|
||||
cat /backup/path/manifest-backup-20250122-123456.json | jq '.application'
|
||||
```
|
||||
|
||||
### Restore History
|
||||
All restore attempts are logged in the `restore_history` table with:
|
||||
- Version compatibility results
|
||||
- Warnings encountered
|
||||
- Success/failure status
|
||||
- Who performed the restore
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Check Compatibility**: Before restoring, verify version compatibility
|
||||
2. **Document Version Changes**: Keep changelog updated with breaking changes
|
||||
3. **Test Restores**: Regularly test restore procedures in staging
|
||||
4. **Monitor Warnings**: Even if compatible, review warnings before proceeding
|
||||
5. **Keep Backups Organized**: Label backups with version info in filename
|
||||
|
||||
## Migration Instructions
|
||||
|
||||
1. Run the new migration:
|
||||
```bash
|
||||
cd backend
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
2. Existing backups will show "unknown" for version fields
|
||||
3. New backups will automatically include version information
|
||||
4. The system remains backward compatible with old backups
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version Mismatch Errors
|
||||
- Check current app version: `cat backend/package.json | grep version`
|
||||
- Check Node version: `node --version`
|
||||
- Check latest migration: `SELECT name FROM knex_migrations ORDER BY id DESC LIMIT 1`
|
||||
|
||||
### Restore Failures
|
||||
- Review `restore_history` table for detailed error messages
|
||||
- Check version compatibility warnings
|
||||
- Consider using same version environment for critical restores
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Automated Version Matching**: Docker containers with specific versions
|
||||
2. **Migration Rollback**: Support for downgrading schema safely
|
||||
3. **Version Matrix**: Compatibility matrix for different version combinations
|
||||
4. **Restore Wizard**: UI for guided restore with compatibility checks
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: January 2025
|
||||
**Current Version**: 1.0.77
|
||||
**Status**: Production Ready
|
||||
@@ -0,0 +1,131 @@
|
||||
const { formatBoolean } = require('./helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding gallery feedback tables...');
|
||||
|
||||
// Create event_feedback_settings table
|
||||
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.boolean('feedback_enabled').defaultTo(false);
|
||||
table.boolean('allow_ratings').defaultTo(true);
|
||||
table.boolean('allow_likes').defaultTo(true);
|
||||
table.boolean('allow_comments').defaultTo(false);
|
||||
table.boolean('allow_favorites').defaultTo(true);
|
||||
table.boolean('require_name_email').defaultTo(false);
|
||||
table.boolean('moderate_comments').defaultTo(true);
|
||||
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['event_id']);
|
||||
});
|
||||
|
||||
// Create photo_feedback table
|
||||
await knex.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('feedback_type', 20).notNullable();
|
||||
table.integer('rating');
|
||||
table.text('comment_text');
|
||||
table.string('guest_name', 100);
|
||||
table.string('guest_email', 255);
|
||||
table.string('guest_identifier', 64);
|
||||
table.string('ip_address', 45);
|
||||
table.text('user_agent');
|
||||
table.boolean('is_approved').defaultTo(true);
|
||||
table.boolean('is_hidden').defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['photo_id']);
|
||||
table.index(['event_id']);
|
||||
table.index(['feedback_type']);
|
||||
table.index(['guest_identifier']);
|
||||
|
||||
// Add check constraint for rating (PostgreSQL)
|
||||
if (knex.client.config.client === 'pg') {
|
||||
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||
}
|
||||
});
|
||||
|
||||
// Create feedback_rate_limits table
|
||||
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier', 64).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('action_type', 20).notNullable();
|
||||
table.integer('action_count').defaultTo(1);
|
||||
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['identifier', 'event_id', 'action_type']);
|
||||
table.index(['window_start']);
|
||||
});
|
||||
|
||||
// Create feedback_word_filters table
|
||||
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('word', 100).notNullable();
|
||||
table.string('severity', 20).defaultTo('moderate');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.unique(['word']);
|
||||
});
|
||||
|
||||
// Add feedback summary columns to photos table
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('feedback_count').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
// Add feedback notification settings to app_settings
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'feedback_notification_email',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'feedback',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'feedback_rate_limits',
|
||||
setting_value: JSON.stringify({
|
||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||
like: { max: 200, window: 3600 } // 200 likes per hour
|
||||
}),
|
||||
setting_type: 'feedback',
|
||||
updated_at: new Date()
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('Gallery feedback tables created successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing gallery feedback tables...');
|
||||
|
||||
// Remove feedback settings from app_settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', ['feedback_notification_email', 'feedback_rate_limits'])
|
||||
.delete();
|
||||
|
||||
// Remove feedback columns from photos table
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('feedback_count');
|
||||
table.dropColumn('like_count');
|
||||
table.dropColumn('average_rating');
|
||||
table.dropColumn('favorite_count');
|
||||
});
|
||||
|
||||
// Drop tables in reverse order
|
||||
await knex.schema.dropTableIfExists('feedback_word_filters');
|
||||
await knex.schema.dropTableIfExists('feedback_rate_limits');
|
||||
await knex.schema.dropTableIfExists('photo_feedback');
|
||||
await knex.schema.dropTableIfExists('event_feedback_settings');
|
||||
|
||||
console.log('Gallery feedback tables removed');
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding version tracking to backup tables...');
|
||||
|
||||
// Add version columns to database_backup_runs table
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (hasDatabaseBackupRunsTable) {
|
||||
const hasAppVersion = await db.schema.hasColumn('database_backup_runs', 'app_version');
|
||||
if (!hasAppVersion) {
|
||||
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||
table.string('app_version'); // Application version
|
||||
table.string('node_version'); // Node.js version
|
||||
table.string('db_schema_version'); // Database schema version (migration name)
|
||||
table.json('environment_info'); // Additional environment information
|
||||
});
|
||||
console.log('Added version columns to database_backup_runs table');
|
||||
}
|
||||
}
|
||||
|
||||
// Add version columns to backup_runs table (file backups)
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
const hasAppVersion = await db.schema.hasColumn('backup_runs', 'app_version');
|
||||
if (!hasAppVersion) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.string('app_version'); // Application version
|
||||
table.string('node_version'); // Node.js version
|
||||
table.string('db_schema_version'); // Database schema version
|
||||
table.json('manifest_info'); // Manifest summary information
|
||||
});
|
||||
console.log('Added version columns to backup_runs table');
|
||||
}
|
||||
}
|
||||
|
||||
// Add restore tracking table
|
||||
const hasRestoreHistoryTable = await db.schema.hasTable('restore_history');
|
||||
if (!hasRestoreHistoryTable) {
|
||||
await db.schema.createTable('restore_history', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed, partial
|
||||
table.string('restore_type'); // database, files, full
|
||||
table.string('backup_id'); // Reference to the backup that was restored
|
||||
table.string('backup_app_version'); // Version of app that created the backup
|
||||
table.string('restore_app_version'); // Version of app performing the restore
|
||||
table.string('backup_node_version'); // Node version that created the backup
|
||||
table.string('restore_node_version'); // Node version performing the restore
|
||||
table.string('backup_schema_version'); // Schema version in the backup
|
||||
table.string('restore_schema_version'); // Current schema version
|
||||
table.json('version_compatibility'); // Compatibility check results
|
||||
table.json('restore_options'); // Options used during restore
|
||||
table.json('statistics'); // Restore statistics
|
||||
table.text('warnings'); // Any warnings during restore
|
||||
table.text('error_message'); // Error details if failed
|
||||
table.string('restored_by'); // User who initiated the restore
|
||||
table.index(['started_at'], 'idx_restore_started');
|
||||
table.index(['backup_id'], 'idx_restore_backup_id');
|
||||
});
|
||||
console.log('Created restore_history table');
|
||||
}
|
||||
|
||||
// Add version compatibility settings
|
||||
const versionSettings = [
|
||||
{
|
||||
setting_key: 'backup_require_version_match',
|
||||
setting_value: JSON.stringify(false), // If true, exact version match required for restore
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_allow_minor_version_mismatch',
|
||||
setting_value: JSON.stringify(true), // Allow restoring from same major version
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_warn_on_version_mismatch',
|
||||
setting_value: JSON.stringify(true), // Show warning when versions don't match
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_check_schema_compatibility',
|
||||
setting_value: JSON.stringify(true), // Check if migrations are compatible
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert version settings if they don't exist
|
||||
for (const setting of versionSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Version tracking for backups added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove version columns from database_backup_runs
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (hasDatabaseBackupRunsTable) {
|
||||
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||
table.dropColumn('app_version');
|
||||
table.dropColumn('node_version');
|
||||
table.dropColumn('db_schema_version');
|
||||
table.dropColumn('environment_info');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove version columns from backup_runs
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.dropColumn('app_version');
|
||||
table.dropColumn('node_version');
|
||||
table.dropColumn('db_schema_version');
|
||||
table.dropColumn('manifest_info');
|
||||
});
|
||||
}
|
||||
|
||||
// Drop restore_history table
|
||||
await db.schema.dropTableIfExists('restore_history');
|
||||
|
||||
// Remove version settings
|
||||
await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'backup_require_version_match',
|
||||
'backup_allow_minor_version_mismatch',
|
||||
'backup_warn_on_version_mismatch',
|
||||
'backup_check_schema_compatibility'
|
||||
])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
Generated
+591
-298
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.75",
|
||||
"version": "1.0.78",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -21,17 +21,17 @@
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"form-data": "^4.0.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"joi": "^17.9.1",
|
||||
@@ -39,12 +39,12 @@
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^6.9.1",
|
||||
"nodemailer": "7.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sharp": "^0.32.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.8.2",
|
||||
|
||||
@@ -205,6 +205,8 @@ app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for the guest
|
||||
*/
|
||||
function generateGuestIdentifier(req) {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${ip}:${userAgent}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit settings from app_settings
|
||||
*/
|
||||
async function getRateLimitSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_key', 'feedback_rate_limits')
|
||||
.first();
|
||||
|
||||
if (settings && settings.setting_value) {
|
||||
return JSON.parse(settings.setting_value);
|
||||
}
|
||||
|
||||
// Default settings
|
||||
return {
|
||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||
like: { max: 200, window: 3600 }, // 200 likes per hour
|
||||
favorite: { max: 100, window: 3600 } // 100 favorites per hour
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting rate limit settings:', error);
|
||||
// Return defaults on error
|
||||
return {
|
||||
rating: { max: 100, window: 3600 },
|
||||
comment: { max: 20, window: 3600 },
|
||||
like: { max: 200, window: 3600 },
|
||||
favorite: { max: 100, window: 3600 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action is rate limited
|
||||
*/
|
||||
async function checkRateLimit(identifier, eventId, actionType) {
|
||||
try {
|
||||
const settings = await getRateLimitSettings();
|
||||
const limit = settings[actionType] || { max: 100, window: 3600 };
|
||||
|
||||
// Clean old entries (older than window)
|
||||
const cutoff = new Date(Date.now() - limit.window * 1000);
|
||||
await db('feedback_rate_limits')
|
||||
.where('window_start', '<', cutoff)
|
||||
.delete();
|
||||
|
||||
// Count recent actions
|
||||
const recentActions = await db('feedback_rate_limits')
|
||||
.where({
|
||||
identifier,
|
||||
event_id: eventId,
|
||||
action_type: actionType
|
||||
})
|
||||
.where('window_start', '>', cutoff)
|
||||
.sum('action_count as total')
|
||||
.first();
|
||||
|
||||
const currentCount = recentActions?.total || 0;
|
||||
|
||||
if (currentCount >= limit.max) {
|
||||
return {
|
||||
limited: true,
|
||||
limit: limit.max,
|
||||
window: limit.window,
|
||||
current: currentCount,
|
||||
resetAt: new Date(Date.now() + limit.window * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
limited: false,
|
||||
limit: limit.max,
|
||||
window: limit.window,
|
||||
current: currentCount,
|
||||
remaining: limit.max - currentCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error checking rate limit:', error);
|
||||
// Allow action on error to avoid blocking legitimate users
|
||||
return { limited: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an action for rate limiting
|
||||
*/
|
||||
async function recordAction(identifier, eventId, actionType) {
|
||||
try {
|
||||
await db('feedback_rate_limits').insert({
|
||||
identifier,
|
||||
event_id: eventId,
|
||||
action_type: actionType,
|
||||
action_count: 1,
|
||||
window_start: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error recording rate limit action:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory for feedback rate limiting
|
||||
*/
|
||||
function feedbackRateLimit(actionType) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Extract event ID from params or body
|
||||
const eventId = req.params.eventId || req.body?.event_id;
|
||||
if (!eventId) {
|
||||
return res.status(400).json({ error: 'Event ID required' });
|
||||
}
|
||||
|
||||
// Generate guest identifier
|
||||
const identifier = generateGuestIdentifier(req);
|
||||
req.guestIdentifier = identifier;
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitStatus = await checkRateLimit(identifier, eventId, actionType);
|
||||
|
||||
// Set rate limit headers
|
||||
res.set({
|
||||
'X-RateLimit-Limit': rateLimitStatus.limit,
|
||||
'X-RateLimit-Remaining': rateLimitStatus.remaining || 0,
|
||||
'X-RateLimit-Reset': rateLimitStatus.resetAt ? rateLimitStatus.resetAt.toISOString() : new Date().toISOString()
|
||||
});
|
||||
|
||||
if (rateLimitStatus.limited) {
|
||||
logger.warn(`Rate limit exceeded for ${actionType}`, {
|
||||
identifier: identifier.substring(0, 16) + '...',
|
||||
eventId,
|
||||
actionType
|
||||
});
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: `Rate limit exceeded. Please try again later.`,
|
||||
retryAfter: rateLimitStatus.window
|
||||
});
|
||||
}
|
||||
|
||||
// Record the action after successful processing
|
||||
res.on('finish', async () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
await recordAction(identifier, eventId, actionType);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Error in rate limit middleware:', error);
|
||||
// Allow request to proceed on error
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IP-based rate limiting for more strict control
|
||||
*/
|
||||
function strictRateLimit(options = {}) {
|
||||
const {
|
||||
windowMs = 15 * 60 * 1000, // 15 minutes
|
||||
max = 100, // limit each IP to 100 requests per windowMs
|
||||
message = 'Too many requests from this IP, please try again later.',
|
||||
skipSuccessfulRequests = false
|
||||
} = options;
|
||||
|
||||
const store = new Map();
|
||||
|
||||
// Clean up old entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, data] of store.entries()) {
|
||||
if (data.resetTime < now) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, windowMs);
|
||||
|
||||
return (req, res, next) => {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const now = Date.now();
|
||||
const resetTime = now + windowMs;
|
||||
|
||||
let data = store.get(ip);
|
||||
if (!data || data.resetTime < now) {
|
||||
data = {
|
||||
count: 0,
|
||||
resetTime
|
||||
};
|
||||
store.set(ip, data);
|
||||
}
|
||||
|
||||
if (data.count >= max) {
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message,
|
||||
retryAfter: Math.ceil((data.resetTime - now) / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
if (!skipSuccessfulRequests || res.statusCode >= 400) {
|
||||
data.count++;
|
||||
}
|
||||
|
||||
res.setHeader('X-RateLimit-Limit', max);
|
||||
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - data.count));
|
||||
res.setHeader('X-RateLimit-Reset', new Date(data.resetTime).toISOString());
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
feedbackRateLimit,
|
||||
strictRateLimit,
|
||||
generateGuestIdentifier,
|
||||
checkRateLimit,
|
||||
recordAction
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -0,0 +1,253 @@
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const validator = require('validator');
|
||||
|
||||
/**
|
||||
* Validation rules for feedback submission
|
||||
*/
|
||||
const feedbackValidationRules = {
|
||||
rating: [
|
||||
body('feedback_type').equals('rating'),
|
||||
body('rating')
|
||||
.isInt({ min: 1, max: 5 })
|
||||
.withMessage('Rating must be between 1 and 5'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters'),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
],
|
||||
|
||||
like: [
|
||||
body('feedback_type').equals('like'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 }),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
],
|
||||
|
||||
favorite: [
|
||||
body('feedback_type').equals('favorite'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 }),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
],
|
||||
|
||||
comment: [
|
||||
body('feedback_type').equals('comment'),
|
||||
body('comment_text')
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Comment cannot be empty')
|
||||
.isLength({ min: 1, max: 1000 })
|
||||
.withMessage('Comment must be between 1 and 1000 characters')
|
||||
.customSanitizer(value => sanitizeComment(value)),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters'),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize comment text
|
||||
*/
|
||||
function sanitizeComment(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove excessive whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Remove zero-width characters
|
||||
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||
|
||||
// Remove control characters
|
||||
text = text.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit consecutive special characters
|
||||
text = text.replace(/([!?.]){4,}/g, '$1$1$1');
|
||||
|
||||
// Remove script tags and other dangerous HTML (basic sanitization)
|
||||
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
text = text.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '');
|
||||
text = text.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '');
|
||||
text = text.replace(/<embed[^>]*>/gi, '');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate feedback type parameter
|
||||
*/
|
||||
const validateFeedbackType = param('feedbackType')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||
.withMessage('Invalid feedback type');
|
||||
|
||||
/**
|
||||
* Validate photo ID parameter
|
||||
*/
|
||||
const validatePhotoId = param('photoId')
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Invalid photo ID');
|
||||
|
||||
/**
|
||||
* Validate event ID parameter
|
||||
*/
|
||||
const validateEventId = param('eventId')
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Invalid event ID');
|
||||
|
||||
/**
|
||||
* Get validation rules based on feedback type
|
||||
*/
|
||||
function getValidationRules(feedbackType) {
|
||||
return feedbackValidationRules[feedbackType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation middleware for feedback submission
|
||||
*/
|
||||
const validateFeedbackSubmission = [
|
||||
body('feedback_type')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||
.withMessage('Invalid feedback type'),
|
||||
|
||||
// Conditional validation based on feedback type
|
||||
body('rating')
|
||||
.if(body('feedback_type').equals('rating'))
|
||||
.isInt({ min: 1, max: 5 })
|
||||
.withMessage('Rating must be between 1 and 5'),
|
||||
|
||||
body('comment_text')
|
||||
.if(body('feedback_type').equals('comment'))
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Comment cannot be empty')
|
||||
.isLength({ min: 1, max: 1000 })
|
||||
.withMessage('Comment must be between 1 and 1000 characters')
|
||||
.customSanitizer(value => sanitizeComment(value)),
|
||||
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters')
|
||||
.matches(/^[a-zA-Z0-9\s\-'.]+$/)
|
||||
.withMessage('Name contains invalid characters'),
|
||||
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation for feedback settings
|
||||
*/
|
||||
const validateFeedbackSettings = [
|
||||
body('feedback_enabled').optional().isBoolean(),
|
||||
body('allow_ratings').optional().isBoolean(),
|
||||
body('allow_likes').optional().isBoolean(),
|
||||
body('allow_comments').optional().isBoolean(),
|
||||
body('allow_favorites').optional().isBoolean(),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean()
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation for word filters
|
||||
*/
|
||||
const validateWordFilter = [
|
||||
body('word')
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Word cannot be empty')
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Word must be between 2 and 100 characters'),
|
||||
body('severity')
|
||||
.optional()
|
||||
.isIn(['mild', 'moderate', 'severe'])
|
||||
.withMessage('Invalid severity level')
|
||||
];
|
||||
|
||||
/**
|
||||
* Check validation results middleware
|
||||
*/
|
||||
const checkValidation = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate guest identity requirements
|
||||
*/
|
||||
async function validateGuestRequirements(settings, guestData) {
|
||||
if (!settings.require_name_email) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!guestData.guest_name || guestData.guest_name.trim().length === 0) {
|
||||
errors.push('Name is required');
|
||||
}
|
||||
|
||||
if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) {
|
||||
errors.push('Valid email is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
feedbackValidationRules,
|
||||
validateFeedbackType,
|
||||
validatePhotoId,
|
||||
validateEventId,
|
||||
validateFeedbackSubmission,
|
||||
validateFeedbackSettings,
|
||||
validateWordFilter,
|
||||
checkValidation,
|
||||
getValidationRules,
|
||||
sanitizeComment,
|
||||
validateGuestRequirements
|
||||
};
|
||||
Generated
+440
-448
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.75",
|
||||
"version": "1.0.78",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.3.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.292.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "^2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -42,7 +42,7 @@
|
||||
"react-image-gallery": "^1.2.11",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-toastify": "^9.1.1",
|
||||
"react-toastify": "11.0.5",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import api from '../../config/api';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
const statusIcons = {
|
||||
completed: { icon: CheckCircle, color: 'text-green-500' },
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FeedbackSettingsProps {
|
||||
settings: FeedbackSettings;
|
||||
onChange: (settings: FeedbackSettings) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleToggle = (field: keyof FeedbackSettings) => {
|
||||
onChange({
|
||||
...settings,
|
||||
[field]: !settings[field]
|
||||
});
|
||||
};
|
||||
|
||||
const handleNumberChange = (field: keyof FeedbackSettings, value: string) => {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
onChange({
|
||||
...settings,
|
||||
[field]: numValue
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
||||
</h2>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.feedback_enabled}
|
||||
onChange={() => handleToggle('feedback_enabled')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.feedback_enabled && (
|
||||
<>
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_ratings}
|
||||
onChange={() => handleToggle('allow_ratings')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Star className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_likes}
|
||||
onChange={() => handleToggle('allow_likes')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Heart className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.likes', 'Likes')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_comments}
|
||||
onChange={() => handleToggle('allow_comments')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.comments', 'Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_favorites}
|
||||
onChange={() => handleToggle('allow_favorites')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Bookmark className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.favorites', 'Favorites')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Privacy & Moderation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.require_name_email}
|
||||
onChange={() => handleToggle('require_name_email')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.moderate_comments}
|
||||
onChange={() => handleToggle('moderate_comments')}
|
||||
disabled={!settings.allow_comments}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
<Shield className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.show_feedback_to_guests}
|
||||
onChange={() => handleToggle('show_feedback_to_guests')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Eye className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enable_rate_limiting}
|
||||
onChange={() => handleToggle('enable_rate_limiting')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{settings.enable_rate_limiting && (
|
||||
<div className="grid grid-cols-2 gap-4 ml-7">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.rate_limit_window_minutes || 15}
|
||||
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
{t('feedback.settings.maxRequests', 'Max Requests')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.rate_limit_max_requests || 10}
|
||||
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import api from '../../config/api';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
const steps = [
|
||||
{ id: 'source', title: 'Select Source' },
|
||||
|
||||
@@ -26,4 +26,5 @@ export { GalleryPreview } from './GalleryPreview';
|
||||
export { BackupDashboard } from './BackupDashboard';
|
||||
export { BackupConfiguration } from './BackupConfiguration';
|
||||
export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageSquare, Send, User, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoCommentsProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
comments: PhotoFeedback[];
|
||||
isEnabled: boolean;
|
||||
requireNameEmail: boolean;
|
||||
showToGuests: boolean;
|
||||
onCommentAdded?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
comments,
|
||||
isEnabled,
|
||||
requireNameEmail,
|
||||
showToGuests,
|
||||
onCommentAdded
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
const [guestEmail, setGuestEmail] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [commentText]);
|
||||
|
||||
const submitCommentMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'comment',
|
||||
comment_text: data.comment_text,
|
||||
guest_name: data.guest_name,
|
||||
guest_email: data.guest_email
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
setCommentText('');
|
||||
setShowCommentForm(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
|
||||
if (response.message) {
|
||||
toast.info(response.message);
|
||||
} else {
|
||||
toast.success(t('feedback.commentSubmitted', 'Comment submitted'));
|
||||
}
|
||||
|
||||
if (onCommentAdded) {
|
||||
onCommentAdded();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before commenting again'));
|
||||
} else if (error.response?.data?.errors) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
toast.error(t('feedback.commentError', 'Failed to submit comment'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
// Validate
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!commentText.trim()) {
|
||||
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||
}
|
||||
if (requireNameEmail) {
|
||||
if (!guestName.trim()) {
|
||||
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||
}
|
||||
if (!guestEmail.trim()) {
|
||||
newErrors.guest_email = t('feedback.emailRequired', 'Email is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
submitCommentMutation.mutate({
|
||||
comment_text: commentText.trim(),
|
||||
guest_name: guestName.trim(),
|
||||
guest_email: guestEmail.trim()
|
||||
});
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
// Filter comments based on visibility settings
|
||||
const visibleComments = showToGuests
|
||||
? comments.filter(c => c.is_approved && !c.is_hidden)
|
||||
: comments.filter(c => c.is_mine);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Comments Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{t('feedback.comments', 'Comments')}
|
||||
{visibleComments.length > 0 && (
|
||||
<span className="text-neutral-500">({visibleComments.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
{!showCommentForm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCommentForm(true)}
|
||||
>
|
||||
{t('feedback.addComment', 'Add Comment')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
{showCommentForm && (
|
||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-3 bg-neutral-50 rounded-lg">
|
||||
{requireNameEmail && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder={t('feedback.yourName', 'Your name')}
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
error={errors.guest_name}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('feedback.yourEmail', 'Your email')}
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
error={errors.guest_email}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('feedback.writeComment', 'Write a comment...')}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
|
||||
}`}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
{errors.comment_text && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{commentText.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
leftIcon={submitCommentMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
disabled={submitCommentMutation.isPending}
|
||||
>
|
||||
{t('feedback.submit', 'Submit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCommentForm(false);
|
||||
setCommentText('');
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
{visibleComments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{visibleComments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-neutral-200 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{format(new Date(comment.created_at), 'PP')}
|
||||
</span>
|
||||
{comment.is_mine && !comment.is_approved && (
|
||||
<span className="text-xs text-orange-600">
|
||||
{t('feedback.pendingApproval', 'Pending approval')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-700 break-words">
|
||||
{comment.comment_text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{visibleComments.length === 0 && !showCommentForm && (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">
|
||||
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
isEnabled: boolean;
|
||||
onFavoriteChange?: (favorited: boolean) => void;
|
||||
}
|
||||
|
||||
export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
isEnabled,
|
||||
onFavoriteChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const submitFavoriteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'favorite'
|
||||
}),
|
||||
onMutate: async () => {
|
||||
setIsSubmitting(true);
|
||||
setAnimating(true);
|
||||
// Optimistic update
|
||||
if (onFavoriteChange) {
|
||||
onFavoriteChange(!isFavorited);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onFavoriteChange) {
|
||||
onFavoriteChange(isFavorited);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
||||
} else {
|
||||
toast.error(t('feedback.favoriteError', 'Failed to update favorite'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setTimeout(() => setAnimating(false), 300);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
submitFavoriteMutation.mutate();
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleFavoriteClick}
|
||||
disabled={isSubmitting}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
isFavorited
|
||||
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
|
||||
>
|
||||
<Bookmark
|
||||
className={`w-5 h-5 transition-all ${
|
||||
animating ? 'scale-125' : 'scale-100'
|
||||
} ${
|
||||
isFavorited ? 'fill-current' : 'group-hover:scale-110'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{favoriteCount > 0 ? favoriteCount : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { PhotoRating } from './PhotoRating';
|
||||
import { PhotoLikes } from './PhotoLikes';
|
||||
import { PhotoComments } from './PhotoComments';
|
||||
import { PhotoFavorites } from './PhotoFavorites';
|
||||
import { Skeleton } from '../common';
|
||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoFeedbackProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
className?: string;
|
||||
showComments?: boolean;
|
||||
onFeedbackUpdate?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
className = '',
|
||||
showComments = true,
|
||||
onFeedbackUpdate
|
||||
}) => {
|
||||
// Fetch feedback settings for the gallery
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['gallery-feedback-settings', gallerySlug],
|
||||
queryFn: () => feedbackService.getGalleryFeedbackSettings(gallerySlug),
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Fetch feedback data for the photo
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||
enabled: !!settings?.feedback_enabled,
|
||||
});
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [currentRating, setCurrentRating] = useState(0);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||
|
||||
// Update local state when data loads
|
||||
useEffect(() => {
|
||||
if (feedbackData) {
|
||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||
setIsLiked(feedbackData.my_feedback.liked);
|
||||
setIsFavorited(feedbackData.my_feedback.favorited);
|
||||
setLikeCount(feedbackData.summary.like_count);
|
||||
setFavoriteCount(feedbackData.summary.favorite_count);
|
||||
}
|
||||
}, [feedbackData]);
|
||||
|
||||
// Handle optimistic updates
|
||||
const handleRatingChange = (rating: number) => {
|
||||
setCurrentRating(rating);
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
const handleLikeChange = (liked: boolean) => {
|
||||
setIsLiked(liked);
|
||||
setLikeCount(prev => liked ? prev + 1 : Math.max(0, prev - 1));
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
const handleFavoriteChange = (favorited: boolean) => {
|
||||
setIsFavorited(favorited);
|
||||
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings?.feedback_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||
settings.allow_comments || settings.allow_favorites;
|
||||
|
||||
if (!hasAnyFeedbackType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Rating Section */}
|
||||
{settings.allow_ratings && (
|
||||
<PhotoRating
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
currentRating={currentRating}
|
||||
averageRating={feedbackData?.summary.average_rating}
|
||||
totalRatings={feedbackData?.summary.total_ratings}
|
||||
isEnabled={true}
|
||||
onRatingChange={handleRatingChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{(settings.allow_likes || settings.allow_favorites) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.allow_likes && (
|
||||
<PhotoLikes
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
isLiked={isLiked}
|
||||
likeCount={likeCount}
|
||||
isEnabled={true}
|
||||
onLikeChange={handleLikeChange}
|
||||
/>
|
||||
)}
|
||||
{settings.allow_favorites && (
|
||||
<PhotoFavorites
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
isFavorited={isFavorited}
|
||||
favoriteCount={favoriteCount}
|
||||
isEnabled={true}
|
||||
onFavoriteChange={handleFavoriteChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments Section */}
|
||||
{settings.allow_comments && showComments && (
|
||||
<div className="border-t pt-4">
|
||||
<PhotoComments
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
comments={feedbackData?.feedback || []}
|
||||
isEnabled={true}
|
||||
requireNameEmail={settings.require_name_email || false}
|
||||
showToGuests={settings.show_feedback_to_guests || false}
|
||||
onCommentAdded={() => {
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -23,6 +24,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
@@ -224,6 +226,14 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowFeedback(!showFeedback)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,6 +269,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||
Swipe to navigate
|
||||
</div>
|
||||
|
||||
{/* Feedback Panel */}
|
||||
{showFeedback && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-96 bg-white shadow-xl z-20 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
|
||||
<button
|
||||
onClick={() => setShowFeedback(false)}
|
||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||
aria-label="Close feedback"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<PhotoFeedback
|
||||
photoId={currentPhoto.id}
|
||||
gallerySlug={slug}
|
||||
showComments={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
isLiked: boolean;
|
||||
likeCount: number;
|
||||
isEnabled: boolean;
|
||||
onLikeChange?: (liked: boolean) => void;
|
||||
}
|
||||
|
||||
export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
isLiked,
|
||||
likeCount,
|
||||
isEnabled,
|
||||
onLikeChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const submitLikeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'like'
|
||||
}),
|
||||
onMutate: async () => {
|
||||
setIsSubmitting(true);
|
||||
setAnimating(true);
|
||||
// Optimistic update
|
||||
if (onLikeChange) {
|
||||
onLikeChange(!isLiked);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onLikeChange) {
|
||||
onLikeChange(isLiked);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
||||
} else {
|
||||
toast.error(t('feedback.likeError', 'Failed to update like'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setTimeout(() => setAnimating(false), 300);
|
||||
}
|
||||
});
|
||||
|
||||
const handleLikeClick = () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
submitLikeMutation.mutate();
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLikeClick}
|
||||
disabled={isSubmitting}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
isLiked
|
||||
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 transition-all ${
|
||||
animating ? 'scale-125' : 'scale-100'
|
||||
} ${
|
||||
isLiked ? 'fill-current' : 'group-hover:scale-110'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{likeCount > 0 ? likeCount : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoRatingProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
currentRating?: number;
|
||||
averageRating?: number;
|
||||
totalRatings?: number;
|
||||
isEnabled: boolean;
|
||||
onRatingChange?: (rating: number) => void;
|
||||
}
|
||||
|
||||
export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
currentRating = 0,
|
||||
averageRating = 0,
|
||||
totalRatings = 0,
|
||||
isEnabled,
|
||||
onRatingChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const submitRatingMutation = useMutation({
|
||||
mutationFn: (rating: number) =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'rating',
|
||||
rating
|
||||
}),
|
||||
onMutate: async (rating) => {
|
||||
setIsSubmitting(true);
|
||||
// Optimistic update
|
||||
if (onRatingChange) {
|
||||
onRatingChange(rating);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
toast.success(t('feedback.ratingSubmitted', 'Rating submitted'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onRatingChange && currentRating) {
|
||||
onRatingChange(currentRating);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before rating again'));
|
||||
} else {
|
||||
toast.error(t('feedback.ratingError', 'Failed to submit rating'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
// If clicking the same rating, remove it
|
||||
const newRating = rating === currentRating ? 0 : rating;
|
||||
submitRatingMutation.mutate(newRating);
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Star Rating Input */}
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
disabled={isSubmitting}
|
||||
className={`p-1 transition-all ${
|
||||
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
|
||||
}`}
|
||||
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
|
||||
>
|
||||
<Star
|
||||
className={`w-6 h-6 transition-colors ${
|
||||
star <= (hoveredRating || currentRating)
|
||||
? 'fill-yellow-500 text-yellow-500'
|
||||
: 'text-neutral-300 hover:text-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Average Rating Display */}
|
||||
{totalRatings > 0 && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
<span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||
<span className="text-neutral-400 ml-1">
|
||||
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,4 +5,9 @@ export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
export { GalleryLayout } from './GalleryLayout';
|
||||
export { PhotoFilterBar } from './PhotoFilterBar';
|
||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||
export { PhotoFeedback } from './PhotoFeedback';
|
||||
export { PhotoRating } from './PhotoRating';
|
||||
export { PhotoLikes } from './PhotoLikes';
|
||||
export { PhotoComments } from './PhotoComments';
|
||||
export { PhotoFavorites } from './PhotoFavorites';
|
||||
@@ -31,7 +31,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||
import api from '../../config/api';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
// Tab components
|
||||
const tabs = [
|
||||
|
||||
@@ -14,7 +14,7 @@ import { addDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -38,6 +38,19 @@ interface FormData {
|
||||
expires_in_days: number;
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
feedback_settings: {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
||||
@@ -83,6 +96,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
expires_in_days: 30,
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
feedback_settings: {
|
||||
feedback_enabled: false,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: true,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
@@ -218,6 +244,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
expiration_days: formData.expires_in_days,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
feedback_settings: formData.feedback_settings,
|
||||
};
|
||||
|
||||
console.log('Submitting payload:', payload);
|
||||
@@ -553,6 +580,12 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<FeedbackSettings
|
||||
settings={formData.feedback_settings}
|
||||
onChange={(settings) => setFormData(prev => ({ ...prev, feedback_settings: settings }))}
|
||||
/>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Upload,
|
||||
Image,
|
||||
Key,
|
||||
Mail
|
||||
Mail,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -319,6 +320,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/admin/events/${id}/feedback`)}
|
||||
>
|
||||
{t('feedback.manage', 'Manage Feedback')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{event.share_link && (
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
MessageSquare,
|
||||
Star,
|
||||
Heart,
|
||||
TrendingUp,
|
||||
Filter,
|
||||
Download,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { FeedbackSettings } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
|
||||
|
||||
export const EventFeedbackPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'feedback' | 'analytics' | 'moderation'>('settings');
|
||||
const [feedbackFilter, setFeedbackFilter] = useState({
|
||||
type: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
limit: 20
|
||||
});
|
||||
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading } = useQuery({
|
||||
queryKey: ['event', id],
|
||||
queryFn: () => eventsService.getEvent(id!),
|
||||
enabled: !!id
|
||||
});
|
||||
|
||||
// Fetch feedback settings
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['feedback-settings', id],
|
||||
queryFn: () => feedbackService.getEventFeedbackSettings(id!),
|
||||
enabled: !!id
|
||||
});
|
||||
|
||||
// Fetch feedback list
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
queryKey: ['event-feedback', id, feedbackFilter],
|
||||
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
||||
enabled: !!id && activeTab === 'feedback'
|
||||
});
|
||||
|
||||
// Fetch analytics
|
||||
const { data: analytics, isLoading: analyticsLoading } = useQuery({
|
||||
queryKey: ['feedback-analytics', id],
|
||||
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
||||
enabled: !!id && activeTab === 'analytics'
|
||||
});
|
||||
|
||||
// Update settings mutation
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] });
|
||||
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
|
||||
}
|
||||
});
|
||||
|
||||
// Moderate feedback mutation
|
||||
const moderateMutation = useMutation({
|
||||
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
|
||||
feedbackService.moderateFeedback(feedbackId, action),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
|
||||
toast.success(t('feedback.moderated', 'Feedback moderated'));
|
||||
}
|
||||
});
|
||||
|
||||
// Delete feedback mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
|
||||
toast.success(t('feedback.deleted', 'Feedback deleted'));
|
||||
}
|
||||
});
|
||||
|
||||
// Export feedback
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
try {
|
||||
const data = await feedbackService.exportEventFeedback(id!, format);
|
||||
if (format === 'csv') {
|
||||
const blob = new Blob([data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `feedback-${event?.slug || id}.csv`;
|
||||
a.click();
|
||||
} else {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `feedback-${event?.slug || id}.json`;
|
||||
a.click();
|
||||
}
|
||||
toast.success(t('feedback.exported', 'Feedback exported'));
|
||||
} catch (error) {
|
||||
toast.error(t('feedback.exportError', 'Failed to export feedback'));
|
||||
}
|
||||
};
|
||||
|
||||
if (eventLoading || settingsLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <div>{t('events.notFound', 'Event not found')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/admin/events/${id}`)}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">
|
||||
{t('feedback.title', 'Feedback Management')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{event.event_name} • {event.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
{t('feedback.exportCSV', 'Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={() => handleExport('json')}
|
||||
>
|
||||
{t('feedback.exportJSON', 'Export JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-neutral-200">
|
||||
<nav className="-mb-px flex gap-6">
|
||||
{[
|
||||
{ id: 'settings', label: t('feedback.tabs.settings', 'Settings'), icon: Shield },
|
||||
{ id: 'feedback', label: t('feedback.tabs.feedback', 'Feedback'), icon: MessageSquare },
|
||||
{ id: 'analytics', label: t('feedback.tabs.analytics', 'Analytics'), icon: TrendingUp },
|
||||
{ id: 'moderation', label: t('feedback.tabs.moderation', 'Moderation'), icon: Filter },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-1 py-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'settings' && settings && (
|
||||
<FeedbackSettings
|
||||
settings={settings}
|
||||
onChange={(newSettings) => updateSettingsMutation.mutate(newSettings)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'feedback' && (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<div className="p-4 flex gap-4">
|
||||
<select
|
||||
value={feedbackFilter.type}
|
||||
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, type: e.target.value, page: 1 })}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="">{t('feedback.allTypes', 'All Types')}</option>
|
||||
<option value="rating">{t('feedback.types.rating', 'Ratings')}</option>
|
||||
<option value="like">{t('feedback.types.like', 'Likes')}</option>
|
||||
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
|
||||
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={feedbackFilter.status}
|
||||
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, status: e.target.value, page: 1 })}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="">{t('feedback.allStatuses', 'All Statuses')}</option>
|
||||
<option value="pending">{t('feedback.status.pending', 'Pending')}</option>
|
||||
<option value="approved">{t('feedback.status.approved', 'Approved')}</option>
|
||||
<option value="hidden">{t('feedback.status.hidden', 'Hidden')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Feedback List */}
|
||||
{feedbackLoading ? (
|
||||
<Loading />
|
||||
) : feedbackData?.feedback?.length === 0 ? (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500">
|
||||
{t('feedback.noFeedback', 'No feedback found')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feedbackData?.feedback?.map((item: PhotoFeedback) => (
|
||||
<Card key={item.id} className="overflow-hidden">
|
||||
<div className="p-4 flex items-start gap-4">
|
||||
<img
|
||||
src={`/thumbnails/${item.path}`}
|
||||
alt={item.filename}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{item.feedback_type === 'rating' && <Star className="w-4 h-4 text-yellow-500" />}
|
||||
{item.feedback_type === 'like' && <Heart className="w-4 h-4 text-red-500" />}
|
||||
{item.feedback_type === 'comment' && <MessageSquare className="w-4 h-4 text-blue-500" />}
|
||||
<span className="font-medium text-sm">
|
||||
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||
</span>
|
||||
{item.guest_email && (
|
||||
<span className="text-xs text-neutral-500">({item.guest_email})</span>
|
||||
)}
|
||||
</div>
|
||||
{item.rating && (
|
||||
<div className="flex gap-1 mb-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= item.rating! ? 'fill-yellow-500 text-yellow-500' : 'text-neutral-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.comment_text && (
|
||||
<p className="text-sm text-neutral-700">{item.comment_text}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{format(new Date(item.created_at), 'PPpp')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.feedback_type === 'comment' && !item.is_approved && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={() => moderateMutation.mutate({
|
||||
feedbackId: item.id.toString(),
|
||||
action: 'approve'
|
||||
})}
|
||||
>
|
||||
{t('feedback.approve', 'Approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
onClick={() => moderateMutation.mutate({
|
||||
feedbackId: item.id.toString(),
|
||||
action: 'hide'
|
||||
})}
|
||||
>
|
||||
{t('feedback.hide', 'Hide')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{item.is_hidden && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
onClick={() => moderateMutation.mutate({
|
||||
feedbackId: item.id.toString(),
|
||||
action: 'approve'
|
||||
})}
|
||||
>
|
||||
{t('feedback.unhide', 'Unhide')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this feedback?'))) {
|
||||
deleteMutation.mutate(item.id.toString());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={feedbackFilter.page === 1}
|
||||
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page - 1 })}
|
||||
>
|
||||
{t('common.previous', 'Previous')}
|
||||
</Button>
|
||||
<span className="flex items-center px-3 text-sm text-neutral-600">
|
||||
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
||||
current: feedbackFilter.page,
|
||||
total: feedbackData.pagination.pages
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={feedbackFilter.page === feedbackData.pagination.pages}
|
||||
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
||||
>
|
||||
{t('common.next', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
{analyticsLoading ? (
|
||||
<Loading />
|
||||
) : analytics ? (
|
||||
<>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Star className="w-8 h-8 text-yellow-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{analytics.summary.average_rating.toFixed(1)}</p>
|
||||
<p className="text-sm text-neutral-600">{t('feedback.avgRating', 'Average Rating')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('feedback.totalRatings', '{{count}} ratings', { count: analytics.summary.total_ratings })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Heart className="w-8 h-8 text-red-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{analytics.summary.total_likes}</p>
|
||||
<p className="text-sm text-neutral-600">{t('feedback.totalLikes', 'Total Likes')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<MessageSquare className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{analytics.summary.total_comments}</p>
|
||||
<p className="text-sm text-neutral-600">{t('feedback.totalComments', 'Total Comments')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{analytics.summary.pending_moderation > 0 && (
|
||||
<p className="text-xs text-orange-600">
|
||||
{t('feedback.pendingModeration', '{{count}} pending', {
|
||||
count: analytics.summary.pending_moderation
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<TrendingUp className="w-8 h-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{analytics.summary.total_feedback}</p>
|
||||
<p className="text-sm text-neutral-600">{t('feedback.totalInteractions', 'Total Interactions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Rated Photos */}
|
||||
{analytics.topRated.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t('feedback.topRated', 'Top Rated Photos')}</h3>
|
||||
<div className="space-y-3">
|
||||
{analytics.topRated.map((photo) => (
|
||||
<div key={photo.id} className="flex items-center justify-between">
|
||||
<span className="text-sm">{photo.filename}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-3 h-3 ${
|
||||
star <= Math.round(photo.average_rating)
|
||||
? 'fill-yellow-500 text-yellow-500'
|
||||
: 'text-neutral-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{photo.average_rating.toFixed(1)} ({photo.feedback_count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Comments */}
|
||||
{analytics.recentComments.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t('feedback.recentComments', 'Recent Comments')}</h3>
|
||||
<div className="space-y-3">
|
||||
{analytics.recentComments.map((comment, idx) => (
|
||||
<div key={idx} className="border-b border-neutral-100 pb-3 last:border-0">
|
||||
<p className="text-sm text-neutral-700">{comment.comment_text}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{comment.guest_name} • {comment.filename} •
|
||||
{format(new Date(comment.created_at), 'PP')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'moderation' && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t('feedback.wordFilters', 'Word Filters')}</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('feedback.wordFiltersDesc', 'Manage blocked words for comment moderation')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => navigate('/admin/settings/moderation')}
|
||||
>
|
||||
{t('feedback.manageFilters', 'Manage Word Filters')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,4 +9,5 @@ export { AnalyticsPage } from './AnalyticsPage';
|
||||
export { BrandingPage } from './BrandingPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
}
|
||||
|
||||
export interface PhotoFeedback {
|
||||
id: number;
|
||||
photo_id: number;
|
||||
event_id: number;
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
is_approved: boolean;
|
||||
is_hidden: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
filename?: string;
|
||||
path?: string;
|
||||
is_mine?: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackSummary {
|
||||
average_rating: number;
|
||||
total_ratings: number;
|
||||
like_count: number;
|
||||
favorite_count: number;
|
||||
comment_count: number;
|
||||
}
|
||||
|
||||
export interface MyFeedback {
|
||||
rating?: number;
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackResponse {
|
||||
feedback: PhotoFeedback[];
|
||||
summary: FeedbackSummary;
|
||||
my_feedback: MyFeedback;
|
||||
}
|
||||
|
||||
export interface FeedbackAnalytics {
|
||||
summary: {
|
||||
total_feedback: number;
|
||||
total_ratings: number;
|
||||
average_rating: number;
|
||||
total_likes: number;
|
||||
total_comments: number;
|
||||
total_favorites: number;
|
||||
pending_moderation: number;
|
||||
};
|
||||
topRated: Array<{
|
||||
id: number;
|
||||
filename: string;
|
||||
average_rating: number;
|
||||
feedback_count: number;
|
||||
like_count: number;
|
||||
}>;
|
||||
mostLiked: Array<{
|
||||
id: number;
|
||||
filename: string;
|
||||
like_count: number;
|
||||
average_rating: number;
|
||||
}>;
|
||||
recentComments: Array<{
|
||||
comment_text: string;
|
||||
guest_name: string;
|
||||
created_at: string;
|
||||
filename: string;
|
||||
}>;
|
||||
timeline: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
feedback_type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
class FeedbackService {
|
||||
// Admin endpoints
|
||||
async getEventFeedbackSettings(eventId: string): Promise<FeedbackSettings> {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateEventFeedbackSettings(eventId: string, settings: FeedbackSettings): Promise<FeedbackSettings> {
|
||||
const response = await api.put(`/admin/feedback/events/${eventId}/feedback-settings`, settings);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEventFeedback(eventId: string, params?: {
|
||||
type?: string;
|
||||
status?: string;
|
||||
photoId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async moderateFeedback(feedbackId: string, action: 'approve' | 'hide' | 'reject') {
|
||||
const response = await api.put(`/admin/feedback/feedback/${feedbackId}/${action}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteFeedback(feedbackId: string) {
|
||||
const response = await api.delete(`/admin/feedback/feedback/${feedbackId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEventFeedbackAnalytics(eventId: string): Promise<FeedbackAnalytics> {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-analytics`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async exportEventFeedback(eventId: string, format: 'json' | 'csv' = 'json') {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
||||
params: { format },
|
||||
responseType: format === 'csv' ? 'blob' : 'json'
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPendingModeration() {
|
||||
const response = await api.get('/admin/feedback/feedback/pending-moderation');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Word filter management
|
||||
async getWordFilters() {
|
||||
const response = await api.get('/admin/feedback/feedback/word-filters');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async addWordFilter(word: string, severity: 'low' | 'moderate' | 'high' = 'moderate') {
|
||||
const response = await api.post('/admin/feedback/feedback/word-filters', { word, severity });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateWordFilter(id: string, updates: { word?: string; severity?: string; is_active?: boolean }) {
|
||||
const response = await api.put(`/admin/feedback/feedback/word-filters/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteWordFilter(id: string) {
|
||||
const response = await api.delete(`/admin/feedback/feedback/word-filters/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Guest endpoints
|
||||
async getGalleryFeedbackSettings(slug: string): Promise<Partial<FeedbackSettings>> {
|
||||
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPhotoFeedback(slug: string, photoId: string): Promise<FeedbackResponse> {
|
||||
const response = await api.get(`/gallery/${slug}/photos/${photoId}/feedback`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async submitFeedback(slug: string, photoId: string, feedback: {
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
}) {
|
||||
const response = await api.post(`/gallery/${slug}/photos/${photoId}/feedback`, feedback);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGalleryFeedbackSummary(slug: string) {
|
||||
const response = await api.get(`/gallery/${slug}/feedback-summary`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getMyFeedback(slug: string) {
|
||||
const response = await api.get(`/gallery/${slug}/my-feedback`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const feedbackService = new FeedbackService();
|
||||
@@ -7,4 +7,5 @@ export { archiveService } from './archive.service';
|
||||
export { emailService } from './email.service';
|
||||
export { settingsService } from './settings.service';
|
||||
export { cmsService } from './cms.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
export { feedbackService } from './feedback.service';
|
||||
@@ -0,0 +1,45 @@
|
||||
# Package Upgrade Summary - Production System
|
||||
Date: 2025-07-22
|
||||
|
||||
## ✅ Successfully Upgraded (8 packages)
|
||||
|
||||
### Phase 1 (Low Risk):
|
||||
**Backend:**
|
||||
- i18next: 25.3.1 → 25.3.2
|
||||
- bcrypt: 5.1.1 → 6.0.0
|
||||
- nodemailer: 6.10.1 → 7.0.5
|
||||
|
||||
**Frontend:**
|
||||
- date-fns: 2.30.0 → 4.1.0
|
||||
- lucide-react: 0.292.0 → 0.525.0
|
||||
|
||||
### Phase 2 (Medium Risk - Carefully Tested):
|
||||
**Backend:**
|
||||
- sharp: 0.32.6 → 0.34.3
|
||||
- chokidar: 3.6.0 → 4.0.3
|
||||
|
||||
**Frontend:**
|
||||
- react-toastify: 9.1.3 → 11.0.5
|
||||
|
||||
## 🚫 Deferred Upgrades (High Risk)
|
||||
|
||||
### Critical Bug Found:
|
||||
- **archiver**: MUST stay at 5.3.2 (v7 has append() bug that breaks watermarks)
|
||||
|
||||
### Major Breaking Changes:
|
||||
- express 4 → 5
|
||||
- knex 2 → 3
|
||||
- React 18 → 19
|
||||
- tailwindcss 3 → 4
|
||||
|
||||
## Security Status
|
||||
- **npm audit vulnerabilities: 0** ✅
|
||||
- All upgraded packages tested and working
|
||||
- No known security issues in current packages
|
||||
|
||||
## Backup Locations
|
||||
- Phase 1: `/backups/phase1-upgrade-20250722-103923/`
|
||||
- Phase 2: `/backups/phase2-upgrade-20250722-104940/`
|
||||
|
||||
## Production Ready
|
||||
All upgrades have been tested and are ready for production deployment. Monitor closely for 48 hours after deployment.
|
||||
Reference in New Issue
Block a user