* feat(feedback): emoji reactions on photos (#839) Per-photo emoji reactions from a fixed curated set (❤️ 😂 😍 👏 🎉), one reaction per guest per photo — same emoji toggles off, another switches in place. Stored as feedback_type='reaction' rows with per-guest scoping identical to likes (guest_id when present, device hash otherwise). - migration 164: allow_reactions toggle (default on, still gated by the opt-in feedback_enabled master switch), photo_feedback.reaction value column, denormalized photos.reaction_count - emoji whitelist enforced in the route validator AND the service (shared constants/reactions.js, mirrored in the frontend) - per-emoji tallies + my_feedback.reaction in the photo feedback endpoint; hidden-by-moderator reactions leave all counts - reactions ride the existing rate limiting (like-tier), guest identity modes, and moderation actions; long + pivot exports carry the emoji - gallery: reaction bar in the photo feedback panel (grid lightbox); admin: allow_reactions toggle next to likes, analytics tile, create/duplicate event paths - i18n for all 8 locales; 9 service-level tests * fix(feedback): reach reactions without comments; numeric analytics totals (#839) - the lightbox feedback-panel toggle was gated on allow_comments only — with comments off the new reaction bar was unreachable; the gate now opens for comments OR reactions - the analytics summary now coerces Postgres string counts to numbers: total_feedback concatenated instead of adding ("00006") * fix(feedback): harden reactions from review round 1 (#839) - per-emoji tallies are gated on show_feedback_to_guests — with sharing off a guest sees only their own selection, no aggregate counts - reaction toggle/switch operate on the guest-scoped row SET, so rows duplicated by the (like-parity) check-then-insert race collapse on the next interaction instead of counting twice - rate-limit defaults merge UNDER the persisted settings object — stored rows predating the reaction key otherwise dropped it to the generic 100/h fallback - optimistic revert uses the pre-mutation value via mutation context; the onError closure sees the post-optimistic render, so the old revert froze the wrong state on failed toggles * fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839) - summary.reaction_count is gated on show_feedback_to_guests like the per-emoji map, keeping the "no aggregates while sharing is off" promise consistent - the admin feedback list renders the reaction emoji on reaction rows and the type filter gains a Reactions option (7 locales; es has no types block and falls back to EN defaults) * fix(feedback): register reaction activity types with translated labels (#839) photo_reaction / guest_feedback_reaction are logged by the submission paths but were absent from the frontend activity-type union and the admin.activities label maps — the recent-activity feed would have shown the raw identifiers. All 8 locales. * feat(feedback): reactions in guest CRM and the premium gallery layout (#839) - guest CRM: per-guest reaction counts in the list aggregation and a Reacted tab (photo grid with emoji badges) + stats card in the guest detail modal; picks/aggregate/exports stay selection-only by design - premium layout: its own yet-another-react-lightbox now gets a fixed reaction-bar overlay (per-photo fetch, optimistic switch) — reactions were otherwise unreachable in this layout since it bypasses the shared PhotoLightbox - allowReactions threaded through the layout feedbackOptions; guest i18n keys for the 7 locales that carry the guests block * fix(feedback): portal the premium reaction bar to document.body (#839) Inside the layout tree an ancestor stacking context (framer-motion transforms) painted the bar under yarl's body-level portal — visible but unclickable, every tap landed on the slide image. As a direct body child the z-index 10000 genuinely wins over yarl's 9999. Verified by clicking through in the running app. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
f8a95d29d2
commit
3d6c9848dc
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Emoji reactions (#839): the fixed, curated reaction set. Guests pick ONE
|
||||
* of these per photo (changeable). Kept as a shared constant so the
|
||||
* validator, the service and the export layer can never drift apart.
|
||||
*
|
||||
* Mirrored in frontend/src/services/feedback.service.ts (REACTION_EMOJIS) —
|
||||
* update both together.
|
||||
*/
|
||||
const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'];
|
||||
|
||||
module.exports = { REACTION_EMOJIS };
|
||||
@@ -34,20 +34,26 @@ async function getRateLimitSettings() {
|
||||
.where('setting_key', 'feedback_rate_limits')
|
||||
.first();
|
||||
|
||||
if (settings && settings.setting_value) {
|
||||
// setting_value is already a JSON object in PostgreSQL
|
||||
return typeof settings.setting_value === 'string'
|
||||
? JSON.parse(settings.setting_value)
|
||||
: settings.setting_value;
|
||||
}
|
||||
|
||||
// Default settings
|
||||
return {
|
||||
// Defaults FIRST, stored values override: persisted rows predate newer
|
||||
// action types (`reaction`, #839) — returning the stored object alone
|
||||
// would silently drop their intended defaults to the generic 100/h.
|
||||
const defaults = {
|
||||
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
|
||||
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
|
||||
reaction: { max: 200, window: 3600 } // reactions churn like likes (#839)
|
||||
};
|
||||
|
||||
if (settings && settings.setting_value) {
|
||||
// setting_value is already a JSON object in PostgreSQL
|
||||
const stored = typeof settings.setting_value === 'string'
|
||||
? JSON.parse(settings.setting_value)
|
||||
: settings.setting_value;
|
||||
return { ...defaults, ...stored };
|
||||
}
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Error getting rate limit settings:', error);
|
||||
// Return defaults on error
|
||||
@@ -55,7 +61,8 @@ async function getRateLimitSettings() {
|
||||
rating: { max: 100, window: 3600 },
|
||||
comment: { max: 20, window: 3600 },
|
||||
like: { max: 200, window: 3600 },
|
||||
favorite: { max: 100, window: 3600 }
|
||||
favorite: { max: 100, window: 3600 },
|
||||
reaction: { max: 200, window: 3600 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ module.exports = (router) => {
|
||||
allow_likes = true,
|
||||
allow_comments = true,
|
||||
allow_favorites = true,
|
||||
allow_reactions = true,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
@@ -490,6 +491,7 @@ module.exports = (router) => {
|
||||
allow_likes: formatBoolean(allow_likes),
|
||||
allow_comments: formatBoolean(allow_comments),
|
||||
allow_favorites: formatBoolean(allow_favorites),
|
||||
allow_reactions: formatBoolean(allow_reactions),
|
||||
require_name_email: formatBoolean(require_name_email),
|
||||
moderate_comments: formatBoolean(moderate_comments),
|
||||
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
||||
@@ -1111,6 +1113,7 @@ module.exports = (router) => {
|
||||
allow_likes: sourceFeedback.allow_likes,
|
||||
allow_comments: sourceFeedback.allow_comments,
|
||||
allow_favorites: sourceFeedback.allow_favorites,
|
||||
allow_reactions: sourceFeedback.allow_reactions,
|
||||
require_name_email: sourceFeedback.require_name_email,
|
||||
moderate_comments: sourceFeedback.moderate_comments,
|
||||
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
|
||||
|
||||
@@ -233,17 +233,22 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
// Number() everywhere: Postgres COUNT() comes back as a string, and
|
||||
// string + string in the total_feedback sum concatenates ("00006").
|
||||
const counts = {
|
||||
total_ratings: Number(summaryData.stats?.total_ratings) || 0,
|
||||
total_likes: Number(summaryData.stats?.total_likes) || 0,
|
||||
total_comments: Number(summaryData.stats?.total_comments) || 0,
|
||||
total_favorites: Number(summaryData.stats?.total_favorites) || 0,
|
||||
total_reactions: Number(summaryData.stats?.total_reactions) || 0,
|
||||
};
|
||||
const summary = {
|
||||
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
|
||||
total_ratings: summaryData.stats?.total_ratings || 0,
|
||||
total_likes: summaryData.stats?.total_likes || 0,
|
||||
total_comments: summaryData.stats?.total_comments || 0,
|
||||
total_favorites: summaryData.stats?.total_favorites || 0,
|
||||
pending_moderation: pendingModeration?.count || 0,
|
||||
total_feedback: (summaryData.stats?.total_ratings || 0) +
|
||||
(summaryData.stats?.total_likes || 0) +
|
||||
(summaryData.stats?.total_comments || 0) +
|
||||
(summaryData.stats?.total_favorites || 0)
|
||||
...counts,
|
||||
pending_moderation: Number(pendingModeration?.count) || 0,
|
||||
total_feedback: counts.total_ratings + counts.total_likes +
|
||||
counts.total_comments + counts.total_favorites +
|
||||
counts.total_reactions
|
||||
};
|
||||
|
||||
// Get top-rated photos
|
||||
|
||||
@@ -78,6 +78,7 @@ router.get(
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'reaction\' THEN 1 END) AS reactions'),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
@@ -89,6 +90,7 @@ router.get(
|
||||
favorites: parseInt(r.favorites, 10) || 0,
|
||||
comments: parseInt(r.comments, 10) || 0,
|
||||
ratings: parseInt(r.ratings, 10) || 0,
|
||||
reactions: parseInt(r.reactions, 10) || 0,
|
||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||
},
|
||||
}));
|
||||
@@ -401,6 +403,7 @@ router.get(
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.created_at',
|
||||
'photos.id as photo_id',
|
||||
'photos.filename',
|
||||
@@ -423,6 +426,7 @@ router.get(
|
||||
favorited: [],
|
||||
rated: [],
|
||||
commented: [],
|
||||
reacted: [],
|
||||
};
|
||||
for (const row of feedback) {
|
||||
if (row.feedback_type === 'like') {
|
||||
@@ -437,6 +441,8 @@ router.get(
|
||||
comment: row.comment_text,
|
||||
created_at: row.created_at,
|
||||
});
|
||||
} else if (row.feedback_type === 'reaction') {
|
||||
selections.reacted.push({ photo: photoFor(row), reaction: row.reaction });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +454,7 @@ router.get(
|
||||
favorites: selections.favorited.length,
|
||||
comments: selections.commented.length,
|
||||
ratings: selections.rated.length,
|
||||
reactions: selections.reacted.length,
|
||||
},
|
||||
},
|
||||
selections,
|
||||
|
||||
@@ -1956,6 +1956,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
allow_reactions: settings.allow_reactions,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests,
|
||||
require_name_email: settings.require_name_email || false,
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
|
||||
@@ -31,6 +31,7 @@ router.get('/:slug/feedback-settings',
|
||||
allow_likes: Boolean(settings.allow_likes),
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
allow_reactions: Boolean(settings.allow_reactions),
|
||||
require_name_email: Boolean(settings.require_name_email),
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||
identity_mode: settings.identity_mode || 'simple',
|
||||
@@ -117,6 +118,9 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
.then(r => r.count),
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0,
|
||||
// Gated like the per-emoji map below — aggregate reaction data is
|
||||
// a new surface, kept fully hidden while sharing is off.
|
||||
reaction_count: settings.show_feedback_to_guests ? (photo.reaction_count || 0) : 0,
|
||||
comment_count: await db('photo_feedback')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
@@ -128,10 +132,17 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
.first()
|
||||
.then(r => r.count)
|
||||
},
|
||||
// Per-emoji tallies for the reaction bar (#839). Gated on
|
||||
// show_feedback_to_guests: with sharing off a guest sees only their
|
||||
// own selection (my_feedback.reaction below), no aggregate counts.
|
||||
reactions: settings.show_feedback_to_guests
|
||||
? await feedbackService.getPhotoReactionCounts(photoId)
|
||||
: {},
|
||||
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')
|
||||
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite'),
|
||||
reaction: guestFeedback.find(f => f.feedback_type === 'reaction')?.reaction || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -181,7 +192,8 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
rating: settings.allow_ratings,
|
||||
like: settings.allow_likes,
|
||||
comment: settings.allow_comments,
|
||||
favorite: settings.allow_favorites
|
||||
favorite: settings.allow_favorites,
|
||||
reaction: settings.allow_reactions
|
||||
};
|
||||
|
||||
if (!typeAllowed[feedbackType]) {
|
||||
@@ -227,6 +239,7 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
feedback_type: feedbackType,
|
||||
rating: req.body.rating,
|
||||
comment_text: req.body.comment_text,
|
||||
reaction: req.body.reaction,
|
||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||
guest_id: req.guest?.id ?? null,
|
||||
@@ -346,7 +359,8 @@ router.get('/:slug/feedback-summary',
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites
|
||||
allow_favorites: settings.allow_favorites,
|
||||
allow_reactions: settings.allow_reactions
|
||||
},
|
||||
summary: guestSummary
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||
|
||||
class FeedbackService {
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ class FeedbackService {
|
||||
allow_likes: true,
|
||||
allow_comments: false,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
@@ -102,13 +104,19 @@ class FeedbackService {
|
||||
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
const { feedback_type, rating, comment_text, reaction, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||
if (!['rating', 'like', 'comment', 'favorite', 'reaction'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
// Reactions come from a fixed curated set — anything else is rejected
|
||||
// (the route validator enforces this too; this is the last line).
|
||||
if (feedback_type === 'reaction' && !REACTION_EMOJIS.includes(reaction)) {
|
||||
throw new Error('Invalid reaction');
|
||||
}
|
||||
|
||||
// Check if similar feedback already exists (prevent duplicates).
|
||||
// When a per-person guest_id is present, scope the check to that guest
|
||||
// so two guests on the same device can independently like a photo.
|
||||
@@ -140,6 +148,41 @@ class FeedbackService {
|
||||
return { id: existing.id, updated: true };
|
||||
}
|
||||
|
||||
// One reaction per guest per photo, changeable (#839): the same
|
||||
// emoji again toggles it off; a different one switches the row.
|
||||
// Toggle/switch act on the full guest-scoped QUERY, not existing.id:
|
||||
// like the sibling like path, the check-then-insert above can race
|
||||
// into duplicate rows — operating on the set makes the next
|
||||
// interaction collapse them instead of leaving a phantom count.
|
||||
const reactionScope = () => {
|
||||
const q = db('photo_feedback').where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type: 'reaction',
|
||||
});
|
||||
if (guest_id) q.where('guest_id', guest_id);
|
||||
else q.where('guest_identifier', guestIdentifier);
|
||||
return q;
|
||||
};
|
||||
if (feedback_type === 'reaction') {
|
||||
if (existing.reaction === reaction) {
|
||||
await reactionScope().delete();
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { removed: true };
|
||||
}
|
||||
// Converge to exactly one row: drop any racy duplicates, then
|
||||
// switch the surviving row's emoji.
|
||||
await reactionScope().whereNot('id', existing.id).delete();
|
||||
await db('photo_feedback')
|
||||
.where('id', existing.id)
|
||||
.update({
|
||||
reaction,
|
||||
updated_at: new Date()
|
||||
});
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { id: existing.id, updated: true };
|
||||
}
|
||||
|
||||
// For likes and favorites, toggle off if already exists.
|
||||
// Toggle-off always allowed — the cap below is on adds only, so a
|
||||
// guest at the limit can still free a slot by un-favoriting (#655).
|
||||
@@ -189,6 +232,7 @@ class FeedbackService {
|
||||
feedback_type,
|
||||
rating: feedback_type === 'rating' ? rating : null,
|
||||
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||
reaction: feedback_type === 'reaction' ? reaction : null,
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
@@ -241,7 +285,7 @@ class FeedbackService {
|
||||
|
||||
const feedback = await query
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||
|
||||
return feedback;
|
||||
} catch (error) {
|
||||
@@ -257,7 +301,7 @@ class FeedbackService {
|
||||
try {
|
||||
const photos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count')
|
||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count')
|
||||
.orderBy('average_rating', 'desc')
|
||||
.orderBy('like_count', 'desc');
|
||||
|
||||
@@ -268,7 +312,8 @@ class FeedbackService {
|
||||
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'])
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction'])
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -282,6 +327,31 @@ class FeedbackService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-emoji reaction counts for one photo (#839): { '❤️': 3, '👏': 1 }.
|
||||
* Only visible rows count — hidden-by-moderator reactions disappear from
|
||||
* the tallies the same way hidden likes leave like_count.
|
||||
*/
|
||||
async getPhotoReactionCounts(photoId) {
|
||||
try {
|
||||
const rows = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'reaction' })
|
||||
.where('is_hidden', false)
|
||||
.groupBy('reaction')
|
||||
.select('reaction')
|
||||
.count('id as count');
|
||||
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
if (row.reaction) counts[row.reaction] = Number(row.count) || 0;
|
||||
}
|
||||
return counts;
|
||||
} catch (error) {
|
||||
logger.error('Error getting photo reaction counts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update photo feedback statistics
|
||||
*/
|
||||
@@ -295,11 +365,12 @@ class FeedbackService {
|
||||
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('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']),
|
||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||
)
|
||||
.first();
|
||||
|
||||
|
||||
// Update photo table
|
||||
await db('photos')
|
||||
.where('id', photoId)
|
||||
@@ -307,7 +378,8 @@ class FeedbackService {
|
||||
feedback_count: stats.feedback_count || 0,
|
||||
like_count: stats.like_count || 0,
|
||||
average_rating: stats.average_rating || 0,
|
||||
favorite_count: stats.favorite_count || 0
|
||||
favorite_count: stats.favorite_count || 0,
|
||||
reaction_count: stats.reaction_count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo feedback stats:', error);
|
||||
@@ -443,6 +515,7 @@ class FeedbackService {
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.created_at'
|
||||
@@ -480,6 +553,7 @@ class FeedbackService {
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.guest_identifier',
|
||||
@@ -505,6 +579,7 @@ class FeedbackService {
|
||||
is_liked: false,
|
||||
star_rating: '',
|
||||
comment: '',
|
||||
reaction: '',
|
||||
latest_at: row.created_at,
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
@@ -531,6 +606,9 @@ class FeedbackService {
|
||||
entry.comment = row.comment_text;
|
||||
}
|
||||
break;
|
||||
case 'reaction':
|
||||
if (row.reaction) entry.reaction = row.reaction;
|
||||
break;
|
||||
default:
|
||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const validator = require('validator');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
|
||||
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||
|
||||
/**
|
||||
* Validation rules for feedback submission
|
||||
@@ -133,14 +134,20 @@ function getValidationRules(feedbackType) {
|
||||
*/
|
||||
const validateFeedbackSubmission = [
|
||||
body('feedback_type')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
|
||||
.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'),
|
||||
|
||||
// Reactions (#839): fixed curated set only — no free-form emoji.
|
||||
body('reaction')
|
||||
.if(body('feedback_type').equals('reaction'))
|
||||
.custom((value) => REACTION_EMOJIS.includes(value))
|
||||
.withMessage('Invalid reaction'),
|
||||
|
||||
body('comment_text')
|
||||
.if(body('feedback_type').equals('comment'))
|
||||
@@ -183,6 +190,7 @@ const validateFeedbackSettings = [
|
||||
body('allow_likes').optional().isBoolean(),
|
||||
body('allow_comments').optional().isBoolean(),
|
||||
body('allow_favorites').optional().isBoolean(),
|
||||
body('allow_reactions').optional().isBoolean(),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean(),
|
||||
|
||||
Reference in New Issue
Block a user