Files
picpeak/backend/src/utils/feedbackValidation.js
T
Paul Nothaft f2814e4a4c feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).

## Schema (migration 141)

Two nullable columns on `event_feedback_settings`:
  - `max_favorites_per_guest`
  - `max_likes_per_guest`

null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.

## Backend

- `feedbackService.submitFeedback` cap check on the INSERT branch only.
  Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
  can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
  matches the exact same guest-key shape the existing duplicate-check
  uses (guest_id when present, fallback to guest_identifier in simple
  identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
  rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
  service-return into a structured 403 with `code:
  'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
  `current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
  optionally render a counter near the heart icon (UI extension TBD;
  the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
  per-field error messages.

## Frontend — the popup

New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.

Mobile-first responsive:
  - `items-end sm:items-center` — slides up from the bottom on phones
    (native action-sheet feel), centers on desktop (familiar modal).
  - `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
    desktop.
  - `rounded-2xl sm:rounded-xl` — more rounded on phones for the
    sheet feel.
  - `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
    and Android gesture bar.
  - `z-[60]` — above the lightbox's z-50.

Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.

New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).

## Admin UI

`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.

## i18n

EN + DE for:
  - Modal title + body (parameterized with `{{limit}}`)
  - Counter pill (parameterized with `{{current}}` / `{{limit}}`)
  - OK button label
  - Admin field labels + hints + section header + grandfathering note

## Tests

**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
  - null cap → unlimited (back-compat)
  - 0 cap → unlimited (UI convenience)
  - cap=10: rows 1-10 succeed, 11 returns limit_reached
  - toggle-off frees a slot at the cap
  - limit reduction grandfathers existing rows
  - per-guest scope: guest A's cap doesn't affect guest B
  - favorite cap doesn't block likes (per-type)
  - like cap returns LIKE_LIMIT_REACHED-shaped payload

**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
  - Non-axios errors → null
  - Non-403 axios errors → null
  - 403 with wrong code → null
  - FAVORITE_LIMIT_REACHED parsed
  - LIKE_LIMIT_REACHED parsed
  - Falls back to code-implied type when feedback_type missing
  - Missing numeric fields → 0 (not NaN)

All 15 pass. tsc --noEmit clean. eslint clean on changed files.

Closes #655.
2026-06-22 22:02:13 +02:00

280 lines
7.8 KiB
JavaScript

const { body, param, validationResult } = require('express-validator');
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
/**
* 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(IDENTITY_PRESERVING_NORMALIZE_EMAIL)
.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(IDENTITY_PRESERVING_NORMALIZE_EMAIL)
],
favorite: [
body('feedback_type').equals('favorite'),
body('guest_name')
.optional()
.trim()
.isLength({ max: 100 }),
body('guest_email')
.optional()
.trim()
.isEmail()
.normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL)
],
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(IDENTITY_PRESERVING_NORMALIZE_EMAIL)
.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"'),
// Per-guest caps (#655). null / 0 = unlimited; positive integers enforced.
// Upper bound is intentionally generous — operators occasionally run
// "everyone, pick everything you like" galleries.
body('max_favorites_per_guest')
.optional({ nullable: true })
.custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000))
.withMessage('max_favorites_per_guest must be null or an integer between 0 and 10000'),
body('max_likes_per_guest')
.optional({ nullable: true })
.custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000))
.withMessage('max_likes_per_guest must be null or an integer between 0 and 10000'),
];
/**
* 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
};