diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index 03cad49a..6519a8a1 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -315,15 +315,21 @@ router.get('/events/:eventId/feedback/export', async (req, res) => { try { const { eventId } = req.params; - const { format = 'json' } = req.query; - - const feedback = await feedbackService.exportEventFeedback(eventId); - + const { format = 'json', shape = 'long' } = req.query; + + // shape='long' (default, backward-compat) → one row per individual + // feedback action. shape='pivot' (#640 part #6) → one row per + // (photo, guest) with is_favorited / is_liked / star_rating / comment. + const isPivot = String(shape).toLowerCase() === 'pivot'; + const feedback = isPivot + ? await feedbackService.exportEventFeedbackPivoted(eventId) + : await feedbackService.exportEventFeedback(eventId); + if (format === 'csv') { - // Convert to CSV const csv = convertToCSV(feedback); + const fileSuffix = isPivot ? 'pivot' : 'long'; res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="feedback-${fileSuffix}-${eventId}.csv"`); res.send(csv); } else { res.json(feedback); @@ -428,24 +434,30 @@ router.delete('/word-filters/:id', } ); -// Helper function to convert JSON to CSV +// Helper function to convert JSON to CSV. Improvements over the original +// (#640 part #6): handles booleans (rendered yes/no for spreadsheet +// readability), nulls/undefined (rendered as empty), and escapes strings +// containing newlines as well as commas/quotes — comments with line breaks +// were silently breaking the CSV row count before this. 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('"'))) { + if (value === null || value === undefined) return ''; + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + if (typeof value === 'string' + && (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) { return `"${value.replace(/"/g, '""')}"`; } - return value || ''; + return value; }).join(','); }); - + return [csvHeaders, ...csvRows].join('\n'); } diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index 0e8c3a05..f2c018d1 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -377,7 +377,9 @@ class FeedbackService { } /** - * Export feedback data for an event + * Export feedback data for an event — long-form (one row per individual + * feedback action: favourite, like, rating, or comment). Backward-compatible + * with archives and any external integrations that consume the existing CSV. */ async exportEventFeedback(eventId) { try { @@ -395,7 +397,7 @@ class FeedbackService { ) .orderBy('photos.filename') .orderBy('photo_feedback.created_at'); - + return feedback; } catch (error) { logger.error('Error exporting feedback:', error); @@ -403,6 +405,97 @@ class FeedbackService { } } + /** + * Export feedback data for an event — pivoted (one row per + * (filename, guest_identifier) pair, columns: is_favorited, is_liked, + * star_rating, comment, latest_at). Useful for spreadsheet pivot tables + * and per-guest engagement scans. Hidden-by-moderator rows are excluded + * because the pivot represents "what the guest currently sees / what we + * want to surface" rather than the raw event log. + * + * Returns the same shape regardless of database driver — pivot is built + * in JS so Postgres / SQLite behave identically. Ported from + * 8digit/picpeak@ed7943b (#640 part #6). + */ + async exportEventFeedbackPivoted(eventId) { + try { + const rows = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.event_id', eventId) + .where('photo_feedback.is_hidden', false) + .select( + 'photos.filename', + 'photo_feedback.feedback_type', + 'photo_feedback.rating', + 'photo_feedback.comment_text', + 'photo_feedback.guest_name', + 'photo_feedback.guest_email', + 'photo_feedback.guest_identifier', + 'photo_feedback.created_at' + ) + .orderBy('photos.filename') + .orderBy('photo_feedback.guest_identifier'); + + const byKey = new Map(); + for (const row of rows) { + // Key needs both the photo and the guest. Anonymous feedback (no + // identifier) gets a synthetic key per row so two anonymous guests' + // actions on the same photo don't collapse together. + const guestKey = row.guest_identifier || `anon-${row.created_at}`; + const key = `${row.filename}::${guestKey}`; + let entry = byKey.get(key); + if (!entry) { + entry = { + filename: row.filename, + guest_name: row.guest_name || '', + guest_email: row.guest_email || '', + is_favorited: false, + is_liked: false, + star_rating: '', + comment: '', + latest_at: row.created_at, + }; + byKey.set(key, entry); + } + // Prefer non-empty contact fields if any row supplied them. + if (!entry.guest_name && row.guest_name) entry.guest_name = row.guest_name; + if (!entry.guest_email && row.guest_email) entry.guest_email = row.guest_email; + + switch (row.feedback_type) { + case 'favorite': + entry.is_favorited = true; + break; + case 'like': + entry.is_liked = true; + break; + case 'rating': + if (row.rating != null) entry.star_rating = row.rating; + break; + case 'comment': + if (row.comment_text) { + // Most recent comment wins. Older comments from the same guest + // on the same photo are dropped — the export is "current state", + // not the comment history. + entry.comment = row.comment_text; + } + break; + default: + // Unknown feedback type — ignore so a future type doesn't break the export. + break; + } + // Track the latest action timestamp across all feedback types. + if (row.created_at && entry.latest_at && row.created_at > entry.latest_at) { + entry.latest_at = row.created_at; + } + } + + return Array.from(byKey.values()); + } catch (error) { + logger.error('Error exporting feedback (pivoted):', error); + throw error; + } + } + /** * Get filtered photos based on feedback criteria * @param {number} eventId - Event ID diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3b77f977..a5c7e19b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3092,7 +3092,10 @@ "onPhoto": "Auf Foto", "showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen", "showAll_other": "Alle {{count}} ausstehenden Kommentare anzeigen", - "viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen" + "viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen", + "exportShapeLabel": "Form", + "exportShapeLong": "Pro Aktion (lang)", + "exportShapePivot": "Pro Gast (pivot)" }, "filter": { "feedbackFilters": "Feedback-Filter", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a7f273e5..389c4b2b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3113,7 +3113,10 @@ "onPhoto": "On photo", "showAll_one": "Show all {{count}} pending comments", "showAll_other": "Show all {{count}} pending comments", - "viewAllFeedback": "View all feedback & settings" + "viewAllFeedback": "View all feedback & settings", + "exportShapeLabel": "Shape", + "exportShapeLong": "Per-action (long)", + "exportShapePivot": "Per-guest (pivot)" }, "filter": { "feedbackFilters": "Feedback Filters", diff --git a/frontend/src/pages/admin/EventFeedbackPage.tsx b/frontend/src/pages/admin/EventFeedbackPage.tsx index 147d6806..4cf14620 100644 --- a/frontend/src/pages/admin/EventFeedbackPage.tsx +++ b/frontend/src/pages/admin/EventFeedbackPage.tsx @@ -41,6 +41,11 @@ export const EventFeedbackPage: React.FC = () => { page: 1, limit: 20 }); + // Export shape selector (#640 #6). 'long' = one row per individual feedback + // action (backward-compat, what the existing export has always been). + // 'pivot' = one row per (photo, guest) — handier for spreadsheet pivot tables + // and per-guest engagement scans, hidden rows excluded. + const [exportShape, setExportShape] = useState<'long' | 'pivot'>('long'); // Fetch event details const { data: event, isLoading: eventLoading } = useQuery({ @@ -101,23 +106,26 @@ export const EventFeedbackPage: React.FC = () => { } }); - // Export feedback + // Export feedback. Filename carries the shape so multiple exports of the + // same event don't overwrite each other in the admin's Downloads folder. const handleExport = async (format: 'json' | 'csv') => { try { - const data = await feedbackService.exportEventFeedback(id!, format); + const data = await feedbackService.exportEventFeedback(id!, format, exportShape); + const eventSlug = event?.slug || id; + const filename = `feedback-${exportShape}-${eventSlug}.${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.download = filename; 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.download = filename; a.click(); } toast.success(t('feedback.exported', 'Feedback exported')); @@ -160,7 +168,23 @@ export const EventFeedbackPage: React.FC = () => {

-
+
+ {/* Shape selector (#640 #6). Long is the existing per-action shape; + pivot is per-(photo, guest) for spreadsheet pivot tables. */} +
+ + +