Compare commits

...

23 Commits

Author SHA1 Message Date
Gitea Actions Bot 95a6ad505d chore: bump version to 1.0.84 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 07:36:39 +00:00
paul 9dc82c8490 fix: replace github-release plugin with direct curl API call
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
The github-release plugin was incorrectly detecting and using the
Gitea API instead of GitHub's API. Replaced with direct curl command
that explicitly calls GitHub API to create releases.

This approach:
- Uses curlimages/curl image for lightweight execution
- Directly calls GitHub API v3 with proper authentication
- Avoids any auto-detection issues from the plugin
- Creates releases with full markdown formatting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 09:35:36 +02:00
paul 554bf1d43c CRITICAL FIX: prevent gallery pages redirecting to admin login
Mirror to GitHub / mirror (push) Successful in 48s
Test and Lint / backend-test (push) Successful in 1m39s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 3s
Users were being redirected from gallery pages to admin login due to
useLocalizedDate hook trying to fetch admin settings. Fixed by:

1. Added general_date_format to public settings endpoint
2. Created publicSettingsService for unauthenticated access
3. Updated useLocalizedDate to use public settings instead of admin
4. Fixed API interceptor to not redirect on public endpoint 401s
5. Added backups/ and test-archiver/ to .gitignore

This restores gallery access for all users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 09:30:35 +02:00
Gitea Actions Bot f147bd6cfb chore: bump backend version to 1.0.83
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 07:13:22 +00:00
paul f564f40ed0 CRITICAL FIX: correct email_templates column names in migration 032
Mirror to GitHub / mirror (push) Successful in 34s
Test and Lint / backend-test (push) Successful in 1m28s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 41s
Version and Release / trigger-drone (push) Successful in 3s
Production failing because email_templates table has different columns.
Fixed column names:
- name → template_key
- subject → subject_en, subject_de
- body → body_html_en, body_html_de, body_text_en, body_text_de
- Added missing 'variables' field
- Removed language and is_active fields (not in schema)

Also fixed the down() function to use template_key instead of name.

URGENT: Production is still down.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 09:08:13 +02:00
Gitea Actions Bot b50cc18045 chore: bump backend version to 1.0.82
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 06:55:08 +00:00
paul 9adb2b04eb fix: remove description field from migration 035 app_settings inserts
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m22s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 41s
Version and Release / trigger-drone (push) Successful in 3s
The app_settings table doesn't have a description column.
Removed all description fields to prevent migration failures.

This completes the fix for all app_settings inserts across migrations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 08:49:56 +02:00
paul 5cce58c233 CRITICAL FIX: remove description field from app_settings inserts
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
continuous-integration/drone/push Build is passing
Production failing with "column description does not exist" error.
The app_settings table only has: id, setting_key, setting_value, setting_type, updated_at
Removed all description fields from migration 032.

URGENT: Production is down - this is blocking the backend from starting.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 08:48:23 +02:00
Gitea Actions Bot c95784eda9 chore: bump backend version to 1.0.81
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 06:35:20 +00:00
paul f49dbed2d8 fix: remove updated_at from app_settings inserts in multiple migrations
Mirror to GitHub / mirror (push) Successful in 32s
Test and Lint / backend-test (push) Successful in 1m25s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
The app_settings table in production doesn't have created_at/updated_at columns.
Fixed inconsistent usage across migrations:
- Migration 014: removed updated_at: new Date()
- Migration 027: removed updated_at: knex.fn.now()
- Migration 033: removed updated_at: new Date()

This ensures all migrations are consistent and won't fail in production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 08:30:19 +02:00
paul 1480fa31c7 CRITICAL FIX: remove created_at/updated_at from migration 032 inserts
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m16s
Version and Release / version-bump (push) Has been cancelled
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
continuous-integration/drone/push Build is passing
Production was failing because app_settings and email_templates
tables don't have created_at/updated_at columns. Removed these
fields from all insert statements to restore service.

This is a critical production fix - system was down.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 08:28:03 +02:00
Gitea Actions Bot 769dff4364 chore: bump backend version to 1.0.80
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 05:30:06 +00:00
paul d2e0e75e9a fix: force github-release plugin to use GitHub API instead of Gitea
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m29s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m3s
The plugin was auto-detecting the Gitea instance and using its API
instead of GitHub's. Fixed by:
- Adding explicit environment variables to override detection
- Removing deprecated github_url/github_upload_url parameters
- Setting DRONE_REMOTE_URL to point to GitHub

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 07:29:14 +02:00
paul 041f3b58f4 fix: remove unused formatBoolean import from migration 033
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m24s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Successful in 42s
continuous-integration/drone/push Build is passing
Version and Release / trigger-drone (push) Successful in 3s
Removed unnecessary import that could cause issues if helpers.js
doesn't define formatBoolean. Migration already uses correct
boolean syntax without the helper.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 07:26:30 +02:00
paul 0f4db08883 fix: remove formatBoolean calls from migration 032 - critical production fix
Mirror to GitHub / mirror (push) Successful in 30s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
continuous-integration/drone/push Build is passing
Migration was failing with "formatBoolean is not a function" error,
preventing backend startup. Fixed by:
- Removing formatBoolean import
- Using direct boolean values for column defaults
- Using JSON.stringify for setting values

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 07:25:10 +02:00
Gitea Actions Bot e0bd8f0707 chore: bump backend version to 1.0.79
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-22 20:36:13 +00:00
paul a8d4500481 fix: resolve migration conflicts and duplicate numbering
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 41s
Version and Release / trigger-drone (push) Successful in 3s
- Rename conflicting migrations to sequential numbers
- Update 035_enhance_backup_system.js to check for existing columns
- Prevent 'column already exists' errors during migration
- Add proper column existence checks before alterations
2025-07-22 22:30:59 +02:00
paul 12f7901d63 fix: correct GitHub repository path in Drone CI release config
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m18s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
- Remove deprecated base_url and upload_url parameters
- Use correct GitHub repository: the-luap/picpeak
- This should resolve the 404 error when creating releases
2025-07-22 15:31:24 +02:00
Gitea Actions Bot 18ddca6c27 chore: bump version to 1.0.78 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-22 13:14:14 +00:00
paul b31f7e6f34 feat: implement gallery feedback system with version tracking for backups
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Successful in 3s
Gallery Feedback Features:
- Add feedback system allowing ratings, likes, comments, and favorites on photos
- Implement admin controls for enabling/disabling feedback per event
- Add content moderation with word filters and spam detection
- Implement rate limiting to prevent abuse (10 requests/15min per type)
- Create comprehensive admin interface for feedback management
- Add analytics dashboard for feedback insights
- Export feedback data when archiving events

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 15:08:52 +02:00
paul 60fdd5d6ef fix: configure github-release plugin to use GitHub API instead of Gitea
Mirror to GitHub / mirror (push) Successful in 29s
Test and Lint / backend-test (push) Successful in 1m34s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
- Add base_url and upload_url pointing to GitHub API
- Explicitly set repo and owner for GitHub repository
- Fixes 401 authentication error in release pipeline
2025-07-22 11:31:13 +02:00
Gitea Actions Bot 0ee7eeaa59 chore: bump version to 1.0.77 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-22 09:20:43 +00:00
paul 811230fb30 Merge branch 'security-updates-form-data-multer' into main
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
Upgrades npm packages for improved security and stability:
- 8 packages upgraded across backend and frontend
- 0 npm audit vulnerabilities maintained
- All upgrades tested and production-ready
2025-07-22 11:15:47 +02:00
43 changed files with 4699 additions and 279 deletions
+26 -39
View File
@@ -110,46 +110,33 @@ steps:
# -------- NEW: Create GitHub Release --------
- name: github-release
image: plugins/github-release
settings:
api_key:
image: curlimages/curl:latest
when:
event: tag
environment:
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
title: "PicPeak ${DRONE_TAG}"
note: |
# PicPeak ${DRONE_TAG}
## 🐳 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).
commands:
- |
# Create release payload
cat > release.json <<EOF
{
"tag_name": "${DRONE_TAG}",
"target_commitish": "main",
"name": "PicPeak ${DRONE_TAG}",
"body": "# PicPeak ${DRONE_TAG}\n\n## 🐳 Docker Images\n\nThis release includes Docker images published to GitHub Container Registry:\n\n\`\`\`bash\n# Backend\ndocker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}\ndocker pull ghcr.io/the-luap/picpeak-backend:latest\n\n# Frontend\ndocker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}\ndocker pull ghcr.io/the-luap/picpeak-frontend:latest\n\`\`\`\n\n## 📦 What's New\n\nSee the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.\n\n## 🚀 Quick Start\n\n\`\`\`bash\n# Clone and deploy\ngit clone https://github.com/the-luap/picpeak.git\ncd picpeak\n\n# Use the tagged version\ndocker-compose -f docker-compose.prod.yml up -d\n\`\`\`\n\n---\n\nFor detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).",
"draft": false,
"prerelease": false
}
EOF
- |
# Create the release on GitHub
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/the-luap/picpeak/releases \
-d @release.json
trigger:
event:
+4
View File
@@ -48,6 +48,10 @@ coverage/
*.tmp
*.temp
# Backup and test directories
backups/
test-archiver/
# Keep directory structure
!storage/events/active/.gitkeep
!storage/events/archived/.gitkeep
+152
View File
@@ -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
@@ -8,8 +8,7 @@ exports.up = async function(knex) {
await knex('app_settings').insert({
setting_key: 'general_default_welcome_message',
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
setting_type: 'general',
updated_at: new Date()
setting_type: 'general'
});
}
@@ -41,8 +41,7 @@ exports.up = async function(knex) {
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
...setting
});
}
}
@@ -1,131 +0,0 @@
exports.up = function(knex) {
return knex.schema
// Add new settings to app_settings table
.table('app_settings', table => {
// S3 configuration enhancements
table.boolean('backup_s3_force_path_style').defaultTo(false).comment('Force path-style S3 URLs (for MinIO/self-hosted)');
table.boolean('backup_s3_ssl_enabled').defaultTo(true).comment('Enable SSL/TLS for S3 connections');
table.string('backup_s3_prefix', 255).comment('S3 key prefix for organizing backups');
// Backup features
table.boolean('backup_incremental').defaultTo(false).comment('Enable incremental backups');
table.boolean('backup_include_database').defaultTo(true).comment('Include database dumps in backups');
table.boolean('backup_manifest_enabled').defaultTo(true).comment('Generate backup manifests');
table.enum('backup_manifest_format', ['json', 'yaml']).defaultTo('json').comment('Manifest file format');
table.boolean('backup_encryption_enabled').defaultTo(false).comment('Enable backup encryption');
table.string('backup_database_schedule', 100).comment('Separate cron schedule for database-only backups');
})
// Enhance backup_runs table
.table('backup_runs', table => {
// Manifest tracking
table.string('manifest_path', 500).comment('Path to backup manifest file');
table.uuid('manifest_id').comment('Unique identifier for the manifest');
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
// Incremental backup support
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
// Add indexes for better query performance
table.index(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.index(['parent_backup_id'], 'idx_backup_runs_parent');
table.index(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
})
// Create backup_manifest table for storing detailed manifest metadata
.createTable('backup_manifest', table => {
table.increments('id').primary();
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
// Backup metadata
table.timestamp('backup_start').notNullable();
table.timestamp('backup_end').notNullable();
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
table.integer('file_count').unsigned().comment('Number of files in backup');
table.integer('photo_count').unsigned().comment('Number of photos backed up');
table.integer('event_count').unsigned().comment('Number of events backed up');
// Incremental backup metadata
table.boolean('is_incremental').defaultTo(false);
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
// Content checksums
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
// Storage information
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
// Encryption metadata
table.boolean('is_encrypted').defaultTo(false);
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
table.string('encryption_key_id', 255).comment('ID of encryption key used');
// Additional metadata as JSON
table.json('metadata').comment('Additional metadata as JSON');
// Timestamps
table.timestamps(true, true);
// Indexes
table.index(['backup_run_id'], 'idx_manifest_backup_run');
table.index(['manifest_id'], 'idx_manifest_uuid');
table.index(['parent_manifest_id'], 'idx_manifest_parent');
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
})
// Add composite indexes for common query patterns
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`)
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at)
WHERE backup_mode = 'incremental';
`);
};
exports.down = function(knex) {
return knex.schema
// Drop indexes first
.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;')
.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;')
// Drop backup_manifest table
.dropTableIfExists('backup_manifest')
// Remove columns from backup_runs table
.table('backup_runs', table => {
table.dropIndex(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.dropIndex(['parent_backup_id'], 'idx_backup_runs_parent');
table.dropIndex(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
table.dropColumn('manifest_path');
table.dropColumn('manifest_id');
table.dropColumn('manifest_format');
table.dropColumn('parent_backup_id');
table.dropColumn('backup_mode');
})
// Remove columns from app_settings table
.table('app_settings', table => {
table.dropColumn('backup_s3_force_path_style');
table.dropColumn('backup_s3_ssl_enabled');
table.dropColumn('backup_s3_prefix');
table.dropColumn('backup_incremental');
table.dropColumn('backup_include_database');
table.dropColumn('backup_manifest_enabled');
table.dropColumn('backup_manifest_format');
table.dropColumn('backup_encryption_enabled');
table.dropColumn('backup_database_schedule');
});
};
@@ -1,4 +1,4 @@
const { formatBoolean, parseBoolean } = require('./helpers');
// No helpers needed for this migration
/**
* Add restore_runs table for tracking restore operations
@@ -30,9 +30,9 @@ exports.up = async function(knex) {
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
// Flags
table.boolean('is_dry_run').defaultTo(formatBoolean(false));
table.boolean('was_rollback_attempted').defaultTo(formatBoolean(false));
table.boolean('was_successful').defaultTo(formatBoolean(false));
table.boolean('is_dry_run').defaultTo(false);
table.boolean('was_rollback_attempted').defaultTo(false);
table.boolean('was_successful').defaultTo(false);
// Operator information
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
@@ -59,7 +59,7 @@ exports.up = async function(knex) {
table.bigInteger('file_size');
table.string('checksum', 64);
table.boolean('checksum_verified').defaultTo(formatBoolean(false));
table.boolean('checksum_verified').defaultTo(false);
table.text('error_message');
table.timestamp('started_at');
@@ -92,60 +92,43 @@ exports.up = async function(knex) {
await knex('app_settings').insert([
{
setting_key: 'restore_allow_force',
setting_value: formatBoolean(false),
setting_type: 'restore',
description: 'Allow force restore with warnings',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_value: JSON.stringify(false),
setting_type: 'restore'
},
{
setting_key: 'restore_require_pre_backup',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Require pre-restore backup',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_value: JSON.stringify(true),
setting_type: 'restore'
},
{
setting_key: 'restore_max_file_size_mb',
setting_value: '5000',
setting_type: 'restore',
description: 'Maximum file size for restore (MB)',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_type: 'restore'
},
{
setting_key: 'restore_verify_checksums',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Verify file checksums during restore',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_value: JSON.stringify(true),
setting_type: 'restore'
},
{
setting_key: 'restore_email_on_completion',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Send email on restore completion',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_value: JSON.stringify(true),
setting_type: 'restore'
},
{
setting_key: 'restore_retention_days',
setting_value: '30',
setting_type: 'restore',
description: 'Days to retain restore history',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
setting_type: 'restore'
}
]);
// Add new email templates for restore notifications
const emailTemplates = [
{
name: 'restore_completed',
subject: '✅ Restore Completed Successfully',
body: `<h2>Restore Operation Completed</h2>
template_key: 'restore_completed',
subject_en: '✅ Restore Completed Successfully',
subject_de: '✅ Wiederherstellung erfolgreich abgeschlossen',
body_html_en: `<h2>Restore Operation Completed</h2>
<p>A restore operation has completed successfully.</p>
<h3>Details:</h3>
@@ -158,36 +141,7 @@ exports.up = async function(knex) {
</ul>
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_failed',
subject: '❌ Restore Operation Failed',
body: `<h2>Restore Operation Failed</h2>
<p>A restore operation has failed and requires attention.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Error:</strong> {{error_message}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please check the system logs for more details and take appropriate action.</p>
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_completed',
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
body_html_de: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
<h3>Details:</h3>
@@ -200,15 +154,50 @@ exports.up = async function(knex) {
</ul>
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
body_text_en: `Restore Operation Completed
A restore operation has completed successfully.
Details:
- Restore Type: {{restore_type}}
- Duration: {{duration}}
- Files Restored: {{files_restored}}
- Backup ID: {{backup_id}}
- Timestamp: {{timestamp}}
Please verify that all systems are functioning correctly after the restore.`,
body_text_de: `Wiederherstellungsvorgang abgeschlossen
Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.
Details:
- Wiederherstellungstyp: {{restore_type}}
- Dauer: {{duration}}
- Wiederhergestellte Dateien: {{files_restored}}
- Backup-ID: {{backup_id}}
- Zeitstempel: {{timestamp}}
Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.`,
variables: JSON.stringify(['restore_type', 'duration', 'files_restored', 'backup_id', 'timestamp'])
},
{
name: 'restore_failed',
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
template_key: 'restore_failed',
subject_en: '❌ Restore Operation Failed',
subject_de: '❌ Wiederherstellungsvorgang fehlgeschlagen',
body_html_en: `<h2>Restore Operation Failed</h2>
<p>A restore operation has failed and requires attention.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Error:</strong> {{error_message}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please check the system logs for more details and take appropriate action.</p>
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
body_html_de: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
<h3>Details:</h3>
@@ -221,10 +210,31 @@ exports.up = async function(knex) {
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
body_text_en: `Restore Operation Failed
A restore operation has failed and requires attention.
Details:
- Restore Type: {{restore_type}}
- Error: {{error_message}}
- Timestamp: {{timestamp}}
Please check the system logs for more details and take appropriate action.
Important: If a pre-restore backup was created, it may be used for recovery.`,
body_text_de: `Wiederherstellungsvorgang fehlgeschlagen
Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.
Details:
- Wiederherstellungstyp: {{restore_type}}
- Fehler: {{error_message}}
- Zeitstempel: {{timestamp}}
Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.
Wichtig: Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.`,
variables: JSON.stringify(['restore_type', 'error_message', 'timestamp'])
}
];
@@ -234,7 +244,7 @@ exports.up = async function(knex) {
exports.down = async function(knex) {
// Remove email templates
await knex('email_templates')
.whereIn('name', ['restore_completed', 'restore_failed'])
.whereIn('template_key', ['restore_completed', 'restore_failed'])
.delete();
// Remove settings
@@ -0,0 +1,129 @@
// No helpers needed for boolean values
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'
},
{
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'
}
]);
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 };
@@ -0,0 +1,221 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Enhancing backup system...');
// Add new settings to app_settings table if they don't exist
const backupSettings = [
{
setting_key: 'backup_s3_force_path_style',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_ssl_enabled',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_prefix',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_incremental',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_include_database',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_encryption_enabled',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_database_schedule',
setting_value: JSON.stringify(''),
setting_type: 'backup'
}
];
// Insert settings if they don't exist
for (const setting of backupSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
// Check and add columns to backup_runs table
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
if (hasBackupRunsTable) {
// Check for existing columns before adding
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
if (!hasManifestPath || !hasManifestId || !hasManifestFormat || !hasParentBackupId || !hasBackupMode) {
await db.schema.alterTable('backup_runs', (table) => {
if (!hasManifestPath) {
table.string('manifest_path', 500).comment('Path to backup manifest file');
}
if (!hasManifestId) {
table.uuid('manifest_id').comment('Unique identifier for the manifest');
}
if (!hasManifestFormat) {
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
}
if (!hasParentBackupId) {
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
}
if (!hasBackupMode) {
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
}
});
}
// Add indexes if they don't exist
try {
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)');
} catch (error) {
console.log('Note: Some indexes may already exist, continuing...');
}
}
// Create backup_manifest table if it doesn't exist
const hasManifestTable = await db.schema.hasTable('backup_manifest');
if (!hasManifestTable) {
await db.schema.createTable('backup_manifest', (table) => {
table.increments('id').primary();
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
// Backup metadata
table.timestamp('backup_start').notNullable();
table.timestamp('backup_end').notNullable();
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
table.integer('file_count').unsigned().comment('Number of files in backup');
table.integer('photo_count').unsigned().comment('Number of photos backed up');
table.integer('event_count').unsigned().comment('Number of events backed up');
// Incremental backup metadata
table.boolean('is_incremental').defaultTo(false);
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
// Content checksums
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
// Storage information
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
// Encryption metadata
table.boolean('is_encrypted').defaultTo(false);
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
table.string('encryption_key_id', 255).comment('ID of encryption key used');
// Additional metadata as JSON
table.json('metadata').comment('Additional metadata as JSON');
// Timestamps
table.timestamps(true, true);
// Indexes
table.index(['backup_run_id'], 'idx_manifest_backup_run');
table.index(['manifest_id'], 'idx_manifest_uuid');
table.index(['parent_manifest_id'], 'idx_manifest_parent');
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
});
}
// Add composite indexes for common query patterns
try {
await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`);
await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at)
WHERE backup_mode = 'incremental';
`);
} catch (error) {
console.log('Note: Some composite indexes may already exist, continuing...');
}
console.log('Backup system enhancements completed');
}
async function down() {
// Drop indexes first
try {
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;');
} catch (error) {
// Ignore errors if indexes don't exist
}
// Drop backup_manifest table
await db.schema.dropTableIfExists('backup_manifest');
// Remove columns from backup_runs if they exist
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
if (hasBackupRunsTable) {
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
if (hasBackupMode || hasParentBackupId || hasManifestFormat || hasManifestId || hasManifestPath) {
await db.schema.alterTable('backup_runs', (table) => {
if (hasBackupMode) table.dropColumn('backup_mode');
if (hasParentBackupId) table.dropColumn('parent_backup_id');
if (hasManifestFormat) table.dropColumn('manifest_format');
if (hasManifestId) table.dropColumn('manifest_id');
if (hasManifestPath) table.dropColumn('manifest_path');
});
}
// Drop indexes
try {
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
} catch (error) {
// Ignore errors if indexes don't exist
}
}
// Remove settings
await db('app_settings')
.whereIn('setting_key', [
'backup_s3_force_path_style',
'backup_s3_ssl_enabled',
'backup_s3_prefix',
'backup_incremental',
'backup_include_database',
'backup_encryption_enabled',
'backup_database_schedule'
])
.delete();
}
module.exports = { up, down };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.75",
"version": "1.0.84",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.75",
"version": "1.0.84",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.75",
"version": "1.0.84",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+2
View File
@@ -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'));
+236
View File
@@ -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
};
+383
View File
@@ -0,0 +1,383 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const {
validateEventId,
validateFeedbackSettings,
validateWordFilter,
checkValidation
} = require('../utils/feedbackValidation');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
// Verify event exists and belongs to admin
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const settings = await feedbackService.getEventFeedbackSettings(eventId);
res.json(settings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
res.status(500).json({ error: 'Failed to get feedback settings' });
}
}
);
// Update event feedback settings
router.put('/events/:eventId/feedback-settings',
adminAuth,
validateEventId,
validateFeedbackSettings,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const settings = req.body;
// Verify event exists
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const updatedSettings = await feedbackService.updateEventFeedbackSettings(eventId, settings);
await logActivity('feedback_settings_updated', {
event_id: eventId,
settings: updatedSettings
}, eventId, {
type: 'admin',
id: req.user.id,
name: req.user.username
});
res.json(updatedSettings);
} catch (error) {
logger.error('Error updating feedback settings:', error);
res.status(500).json({ error: 'Failed to update feedback settings' });
}
}
);
// Get feedback for an event (with filters)
router.get('/events/:eventId/feedback',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const { type, status, photoId, page = 1, limit = 50 } = req.query;
// Build query
let query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', eventId)
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
);
if (type) {
query = query.where('photo_feedback.feedback_type', type);
}
if (status === 'pending') {
query = query.where('photo_feedback.is_approved', false)
.where('photo_feedback.is_hidden', false);
} else if (status === 'approved') {
query = query.where('photo_feedback.is_approved', true);
} else if (status === 'hidden') {
query = query.where('photo_feedback.is_hidden', true);
}
if (photoId) {
query = query.where('photo_feedback.photo_id', photoId);
}
// Pagination
const offset = (page - 1) * limit;
const totalCount = await query.clone().count('photo_feedback.id as count').first();
const feedback = await query
.orderBy('photo_feedback.created_at', 'desc')
.limit(limit)
.offset(offset);
res.json({
feedback,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount.count || 0,
pages: Math.ceil((totalCount.count || 0) / limit)
}
});
} catch (error) {
logger.error('Error getting feedback:', error);
res.status(500).json({ error: 'Failed to get feedback' });
}
}
);
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
async (req, res) => {
try {
const { feedbackId, action } = req.params;
if (!['approve', 'hide', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
res.json({ success: true });
} catch (error) {
logger.error('Error moderating feedback:', error);
res.status(500).json({ error: 'Failed to moderate feedback' });
}
}
);
// Delete feedback
router.delete('/feedback/:feedbackId',
adminAuth,
async (req, res) => {
try {
const { feedbackId } = req.params;
await feedbackService.deleteFeedback(feedbackId, req.user.id);
res.json({ success: true });
} catch (error) {
logger.error('Error deleting feedback:', error);
res.status(500).json({ error: 'Failed to delete feedback' });
}
}
);
// Get feedback analytics for an event
router.get('/events/:eventId/feedback-analytics',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
// Get summary statistics
const summary = await feedbackService.getEventFeedbackSummary(eventId);
// Get top-rated photos
const topRated = await db('photos')
.where('event_id', eventId)
.where('average_rating', '>', 0)
.orderBy('average_rating', 'desc')
.orderBy('feedback_count', 'desc')
.limit(10)
.select('id', 'filename', 'average_rating', 'feedback_count', 'like_count');
// Get most liked photos
const mostLiked = await db('photos')
.where('event_id', eventId)
.where('like_count', '>', 0)
.orderBy('like_count', 'desc')
.limit(10)
.select('id', 'filename', 'like_count', 'average_rating');
// Get recent comments
const recentComments = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', eventId)
.where('photo_feedback.feedback_type', 'comment')
.where('photo_feedback.is_approved', true)
.where('photo_feedback.is_hidden', false)
.orderBy('photo_feedback.created_at', 'desc')
.limit(10)
.select(
'photo_feedback.comment_text',
'photo_feedback.guest_name',
'photo_feedback.created_at',
'photos.filename'
);
// Get feedback timeline (last 7 days)
const timeline = await db('photo_feedback')
.where('event_id', eventId)
.where('created_at', '>', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.select(
db.raw('DATE(created_at) as date'),
db.raw('COUNT(*) as count'),
'feedback_type'
)
.groupBy('date', 'feedback_type')
.orderBy('date', 'asc');
res.json({
summary,
topRated,
mostLiked,
recentComments,
timeline
});
} catch (error) {
logger.error('Error getting feedback analytics:', error);
res.status(500).json({ error: 'Failed to get feedback analytics' });
}
}
);
// Export feedback data
router.get('/events/:eventId/feedback/export',
adminAuth,
validateEventId,
checkValidation,
async (req, res) => {
try {
const { eventId } = req.params;
const { format = 'json' } = req.query;
const feedback = await feedbackService.exportEventFeedback(eventId);
if (format === 'csv') {
// Convert to CSV
const csv = convertToCSV(feedback);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`);
res.send(csv);
} else {
res.json(feedback);
}
} catch (error) {
logger.error('Error exporting feedback:', error);
res.status(500).json({ error: 'Failed to export feedback' });
}
}
);
// Get pending moderation items (across all events)
router.get('/feedback/pending-moderation',
adminAuth,
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
res.json(pending);
} catch (error) {
logger.error('Error getting pending moderation:', error);
res.status(500).json({ error: 'Failed to get pending moderation' });
}
}
);
// Word filter management
router.get('/feedback/word-filters',
adminAuth,
async (req, res) => {
try {
const filters = await feedbackModeration.getAllWordFilters();
res.json(filters);
} catch (error) {
logger.error('Error getting word filters:', error);
res.status(500).json({ error: 'Failed to get word filters' });
}
}
);
router.post('/feedback/word-filters',
adminAuth,
validateWordFilter,
checkValidation,
async (req, res) => {
try {
const { word, severity = 'moderate' } = req.body;
await feedbackModeration.addWordFilter(word, severity);
await logActivity('word_filter_added', { word, severity }, null, {
type: 'admin',
id: req.user.id,
name: req.user.username
});
res.json({ success: true });
} catch (error) {
if (error.message === 'Word filter already exists') {
return res.status(409).json({ error: error.message });
}
logger.error('Error adding word filter:', error);
res.status(500).json({ error: 'Failed to add word filter' });
}
}
);
router.put('/feedback/word-filters/:id',
adminAuth,
async (req, res) => {
try {
const { id } = req.params;
const updates = req.body;
await feedbackModeration.updateWordFilter(id, updates);
res.json({ success: true });
} catch (error) {
logger.error('Error updating word filter:', error);
res.status(500).json({ error: 'Failed to update word filter' });
}
}
);
router.delete('/feedback/word-filters/:id',
adminAuth,
async (req, res) => {
try {
const { id } = req.params;
await feedbackModeration.deleteWordFilter(id);
res.json({ success: true });
} catch (error) {
logger.error('Error deleting word filter:', error);
res.status(500).json({ error: 'Failed to delete word filter' });
}
}
);
// Helper function to convert JSON to CSV
function convertToCSV(data) {
if (!data || data.length === 0) return '';
const headers = Object.keys(data[0]);
const csvHeaders = headers.join(',');
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
}).join(',');
});
return [csvHeaders, ...csvRows].join('\n');
}
module.exports = router;
+342
View File
@@ -0,0 +1,342 @@
const express = require('express');
const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const {
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
validateGuestRequirements
} = require('../utils/feedbackValidation');
const { escapeLikePattern } = require('../utils/sqlSecurity');
// Get feedback settings for a gallery
router.get('/:slug/feedback-settings',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
const guestSettings = {
feedback_enabled: settings.feedback_enabled,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
require_name_email: settings.require_name_email,
show_feedback_to_guests: settings.show_feedback_to_guests
};
res.json(guestSettings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
res.status(500).json({ error: 'Failed to get feedback settings' });
}
}
);
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
validatePhotoId,
checkValidation,
async (req, res) => {
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get feedback based on settings
const options = {
approved_only: true,
include_hidden: false
};
// Include guest's own feedback even if not approved
const feedback = await feedbackService.getPhotoFeedback(photoId, options);
// Get guest's own feedback separately
const guestFeedback = await feedbackService.getPhotoFeedback(photoId, {
guest_identifier: guestIdentifier
});
// Combine and deduplicate
const allFeedback = [...feedback];
guestFeedback.forEach(gf => {
if (!feedback.find(f => f.id === gf.id)) {
allFeedback.push({ ...gf, is_mine: true });
} else {
const index = allFeedback.findIndex(f => f.id === gf.id);
allFeedback[index].is_mine = true;
}
});
// Filter based on what guests should see
const visibleFeedback = settings.show_feedback_to_guests ? allFeedback :
allFeedback.filter(f => f.is_mine);
res.json({
feedback: visibleFeedback,
summary: {
average_rating: photo.average_rating || 0,
total_ratings: await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'rating', is_hidden: false })
.count('id as count')
.first()
.then(r => r.count),
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0,
comment_count: await db('photo_feedback')
.where({
photo_id: photoId,
feedback_type: 'comment',
is_approved: true,
is_hidden: false
})
.count('id as count')
.first()
.then(r => r.count)
},
my_feedback: {
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite')
}
});
} catch (error) {
logger.error('Error getting photo feedback:', error);
res.status(500).json({ error: 'Failed to get feedback' });
}
}
);
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
async (req, res) => {
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// Check if specific feedback type is allowed
const feedbackType = req.body.feedback_type;
const typeAllowed = {
rating: settings.allow_ratings,
like: settings.allow_likes,
comment: settings.allow_comments,
favorite: settings.allow_favorites
};
if (!typeAllowed[feedbackType]) {
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Validate guest requirements
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
}
// Apply rate limiting based on feedback type
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
await new Promise((resolve, reject) => {
rateLimitMiddleware(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
// If we got here and response was sent (rate limited), return
if (res.headersSent) return;
// Prepare feedback data
const feedbackData = {
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent'],
moderate_comments: settings.moderate_comments
};
// For comments, check moderation
if (feedbackType === 'comment') {
// Check user reputation
const reputation = await feedbackModeration.checkUserReputation(guestIdentifier, event.id);
// Moderate the comment
const moderationResult = await feedbackModeration.moderateText(req.body.comment_text);
if (!moderationResult.approved) {
// Still save but mark as not approved
feedbackData.is_approved = false;
logger.warn('Comment flagged for moderation:', {
reason: moderationResult.reason,
violations: moderationResult.violations
});
} else if (reputation.autoApprove) {
// Trusted user, auto-approve
feedbackData.is_approved = true;
} else if (settings.moderate_comments) {
// Default moderation setting
feedbackData.is_approved = false;
}
}
// Submit feedback
const result = await feedbackService.submitFeedback(
photoId,
event.id,
feedbackData,
guestIdentifier
);
// Log activity
await logActivity(`guest_feedback_${feedbackType}`, {
photo_id: photoId,
result
}, event.id, {
type: 'guest',
id: guestIdentifier.substring(0, 16),
name: req.body.guest_name || 'Anonymous'
});
res.json({
success: true,
...result,
message: feedbackType === 'comment' && !feedbackData.is_approved ?
'Your comment has been submitted for moderation' : undefined
});
} catch (error) {
logger.error('Error submitting feedback:', error);
res.status(500).json({ error: 'Failed to submit feedback' });
}
}
);
// Get feedback summary for entire gallery
router.get('/:slug/feedback-summary',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled || !settings.show_feedback_to_guests) {
return res.json({
enabled: false,
summary: null
});
}
const summary = await feedbackService.getEventFeedbackSummary(event.id);
// Filter data based on what guests should see
const guestSummary = {
stats: summary.stats,
top_rated: summary.photos
.filter(p => p.average_rating > 0)
.slice(0, 5)
.map(p => ({
id: p.id,
filename: p.filename,
average_rating: p.average_rating,
like_count: p.like_count
}))
};
res.json({
enabled: true,
settings: {
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites
},
summary: guestSummary
});
} catch (error) {
logger.error('Error getting feedback summary:', error);
res.status(500).json({ error: 'Failed to get feedback summary' });
}
}
);
// Get user's own feedback for all photos
router.get('/:slug/my-feedback',
verifyGalleryAccess,
async (req, res) => {
try {
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
const myFeedback = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id)
.where('photo_feedback.guest_identifier', guestIdentifier)
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
)
.orderBy('photo_feedback.created_at', 'desc');
res.json(myFeedback);
} catch (error) {
logger.error('Error getting user feedback:', error);
res.status(500).json({ error: 'Failed to get your feedback' });
}
}
);
module.exports = router;
+1
View File
@@ -45,6 +45,7 @@ router.get('/', async (req, res) => {
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
general_date_format: settingsObject.general_date_format || 'PPP',
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
+53
View File
@@ -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 };
+35 -3
View File
@@ -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,
+92 -5
View File
@@ -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.');
}
}
+312
View File
@@ -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();
+393
View File
@@ -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();
+253
View File
@@ -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
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.76",
"version": "1.0.84",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.76",
"version": "1.0.84",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.76",
"version": "1.0.84",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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>
);
};
+2 -1
View File
@@ -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>
);
};
+6 -1
View File
@@ -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';
+2 -2
View File
@@ -83,8 +83,8 @@ api.interceptors.response.use(
}
if (error.response?.status === 401) {
// Check if it's an admin route
const isAdminRoute = error.config?.url?.includes('/admin');
// Check if it's an admin route (but not public endpoints)
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
const currentPath = window.location.pathname;
if (isAdminRoute) {
+5 -4
View File
@@ -2,16 +2,17 @@ import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../services/settings.service';
import { publicSettingsService } from '../services/publicSettings.service';
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
// Fetch admin settings to get the date format
// Fetch public settings to get the date format
const { data: settings } = useQuery({
queryKey: ['admin-settings-general'],
queryFn: () => settingsService.getSettingsByType('general'),
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 1, // Only retry once to avoid blocking the UI
});
const getLocale = () => {
@@ -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
+10 -1
View File
@@ -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>
);
};
+2 -1
View File
@@ -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';
+196
View File
@@ -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();
+2 -1
View File
@@ -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,34 @@
import { api } from '../config/api';
export interface PublicSettings {
branding_company_name: string;
branding_company_tagline: string;
branding_support_email: string;
branding_footer_text: string;
branding_watermark_enabled: boolean;
branding_watermark_logo_url: string;
branding_watermark_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center';
branding_watermark_opacity: number;
branding_watermark_size: number;
branding_favicon_url: string;
branding_logo_url: string;
theme_config: any;
default_language: string;
enable_analytics: boolean;
general_date_format: string;
enable_recaptcha: boolean;
recaptcha_site_key: string | null;
maintenance_mode: boolean;
umami_enabled: boolean;
umami_url: string | null;
umami_website_id: string | null;
umami_share_url: string | null;
}
export const publicSettingsService = {
// Get public settings (no authentication required)
async getPublicSettings(): Promise<PublicSettings> {
const response = await api.get<PublicSettings>('/public/settings');
return response.data;
}
};