Introduces a new "Per-guest selections" identity mode for event feedback, letting each visitor register under their own name so their likes/favorites/comments/ratings are tracked independently. Includes admin insights (list, per-guest detail, aggregate view, export) and advanced identity features (forget-me, email recovery, invite tokens, merge). New event-level setting - event_feedback_settings.identity_mode = 'simple' | 'guest' (default 'simple' → zero behavior change for existing events). - Admin UI radio under Feedback Settings to toggle per event. Root cause of the previous "all guests share state" bug - generateGuestIdentifier() was sha256(ip + userAgent), so every visitor on the same WiFi + similar device collided into one identity. - Now: when a verified guest JWT is present (x-guest-token header), req.guest.identifier takes precedence — per-person rate limits and per-person deduplication. Phase 1 — identity layer - Migration 078: new gallery_guests, guest_invites, guest_verification_ codes tables; identity_mode column + check constraint; nullable guest_id FK on photo_feedback. - New guest JWT type scoped to (eventId, guestId). - New middleware guestAuth.resolveGuest (non-blocking) + requireGuest. - POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me. - Gallery feedback route enforces guest identity in guest mode and reads name/email from the verified token (never from the body). - Frontend GuestIdentityContext + GuestNamePromptModal; axios interceptor injects x-guest-token on gallery API calls. - Feedback-only blocking: gallery opens freely, prompt only on first interactive feedback action. - Admin "Guests" tab (conditional on identity_mode='guest') with the AdminGuestsList component. Phase 2 — admin insights - GET /admin/events/:eventId/guests list + aggregated counts. - GET /admin/events/:eventId/guests/:guestId detail with per-type groupings; AdminGuestDetail modal with thumbnail grid + tabs. - GET /admin/events/:eventId/guests/aggregate sorted by distinct guest pick count; GuestSelectionsAggregate component. - Per-guest export (txt/csv/json) and bulk export-all ZIP. Phase 3 — polish - 3.1 Self-service forget-me link in gallery footer. - 3.2 Email-based identity recovery: POST /guest/recover sends a 6-digit code via the existing emailProcessor, POST /guest/verify exchanges it for a token (rate-limited, enumeration-safe). - 3.3 Admin invite tokens: pre-mint identities, share URLs with ?invite=, single-use redemption stripping the param from history. - 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources. Shared helper - useGalleryFeedbackAction hook wraps the identity-check logic for inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/ Timeline/Premium layouts. Backwards compatibility - Existing events default to 'simple' after migration; behavior unchanged. - Legacy photo_feedback rows keep guest_id NULL; admin shows them in the generic feedback moderation view as before. - feedback_count denormalized stat now uses COALESCE(guest_id, guest_identifier) so per-guest counts are accurate without touching legacy rows. Verified end-to-end against local Docker - Migration clean on existing data. - Simple mode unchanged (no prompt, legacy flow). - Guest mode: Alice registers on click, tokens persist in sessionStorage, feedback rows carry guest_id. - Carol via invite link auto-redeems, sees Alice's "1 likes" badge. - Admin Guests tab shows both with correct counts; detail modal displays thumbnail grid with badges; aggregate view sorts by picker count (photo 227 = 2, others = 1); CSV/JSON export matches DB. - Merge Carol into Alice: feedback reassigned, Carol soft-deleted, Alice count = 4.
268 lines
7.0 KiB
JavaScript
268 lines
7.0 KiB
JavaScript
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()
|
|
.custom((value) => {
|
|
// Allow empty or whitespace-only strings
|
|
if (!value || value.trim() === '') return true;
|
|
// If not empty, check length and pattern
|
|
const trimmed = value.trim();
|
|
if (trimmed.length > 100) throw new Error('Name must be less than 100 characters');
|
|
if (!/^[a-zA-Z0-9\s\-'.]+$/.test(trimmed)) throw new Error('Name contains invalid characters');
|
|
return true;
|
|
}),
|
|
|
|
body('guest_email')
|
|
.optional()
|
|
.custom((value) => {
|
|
// Allow empty or whitespace-only strings
|
|
if (!value || value.trim() === '') return true;
|
|
// If not empty, validate as email
|
|
if (!validator.isEmail(value.trim())) throw new Error('Invalid email address');
|
|
return true;
|
|
})
|
|
];
|
|
|
|
/**
|
|
* 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(),
|
|
body('identity_mode').optional().isIn(['simple', 'guest'])
|
|
.withMessage('identity_mode must be "simple" or "guest"')
|
|
];
|
|
|
|
/**
|
|
* 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 = [];
|
|
|
|
// Check for name - handle both undefined and empty strings
|
|
const name = guestData.guest_name;
|
|
if (!name || (typeof name === 'string' && name.trim().length === 0)) {
|
|
errors.push('Name is required');
|
|
}
|
|
|
|
// Check for email - handle both undefined and empty strings
|
|
const email = guestData.guest_email;
|
|
if (!email || (typeof email === 'string' && email.trim().length === 0)) {
|
|
errors.push('Email is required');
|
|
} else if (email && typeof email === 'string' && !validator.isEmail(email.trim())) {
|
|
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
|
|
}; |