From fabd67aecd6caf308956e5b4cb9df7dd44452142 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 18 Jun 2026 23:08:55 +0200
Subject: [PATCH] =?UTF-8?q?feat(feedback):=20export=20shape=20toggle=20?=
=?UTF-8?q?=E2=80=94=20per-action=20vs=20per-guest=20pivot=20(#640=20part?=
=?UTF-8?q?=20E)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The
current per-action shape (one row per favourite/like/rating/comment) stays
the default for backward compat with any external scripts consuming the
export; the new pivot shape (one row per (photo, guest_identifier) with
boolean is_favorited/is_liked + star_rating + comment) is opt-in via a
?shape=pivot query param and a dropdown in the admin feedback page.
Pivot wins for "which guests engaged with which photos" analysis in
Sheets / Excel pivot tables. Long wins for engagement timeline analysis
and re-importing into another tool. Different products, both valid.
### Backend
- `feedbackService.exportEventFeedbackPivoted(eventId)`: new method.
LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically.
Key is `(filename, guest_identifier)` — anonymous guests with no
identifier get a synthetic per-row key so two anonymous comments on the
same photo don't collapse. Comments: most recent wins (history dropped
in exchange for "current state" semantics). Hidden-by-moderator rows
excluded — the pivot represents what we want to surface, not the raw
event log.
- `adminFeedback.js` export route: accepts `?shape=pivot|long` (default
`long`). CSV filename now carries the shape (e.g.
`feedback-pivot-{id}.csv`) so repeated exports don't overwrite.
- `convertToCSV` helper in `adminFeedback.js` gains the three escaping
improvements that 8digit's commit also shipped: booleans → `yes`/`no`,
null/undefined → empty, escape strings containing newlines (\n/\r) as
well as commas/quotes. Comments with line breaks were silently breaking
CSV row counts before this. Improvements are pure wins regardless of
shape; archives' own `convertToCSV` copy left untouched (separate
surface, no behaviour drift risk).
### Frontend
- `feedback.service.ts` `exportEventFeedback()` gains optional `shape`
parameter, default 'long'.
- `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON
buttons (defaults to 'long'). Selected shape flows through to the API
request AND the downloaded filename.
### i18n
3 new EN + DE entries (`feedback.exportShapeLabel`,
`feedback.exportShapeLong`, `feedback.exportShapePivot`).
### Notes
- Pivot shape is **per-guest current state**, not history. A guest who
rated a photo, then changed their mind and removed the rating, would
show the final state in the pivot but BOTH actions in the long form.
Acceptable trade-off: pivot users care about the snapshot, long users
want the trail.
- `latest_at` column in pivot gives a "most recent activity" timestamp
per row, useful for sorting/filtering recent engagement.
### Test plan
- [x] Backend syntax + TS check + lint clean (no new warnings; existing
`catch (error)` warning was pre-existing)
- [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV →
verify one row per (filename, guest) with is_favorited='yes'/'no',
latest_at column populated
- [ ] Manual: long shape default still produces the same per-action
output as before (no regression for existing consumers)
- [ ] Manual: comment containing a newline → pivot CSV escapes correctly,
row count matches data length + 1 header
- [ ] Manual: archive a published event with feedback → archive's
`feedback_data.csv` still uses the long shape (archive surface
unchanged on purpose)
---
backend/src/routes/adminFeedback.js | 38 +++++---
backend/src/services/feedbackService.js | 97 ++++++++++++++++++-
frontend/src/i18n/locales/de.json | 5 +-
frontend/src/i18n/locales/en.json | 5 +-
.../src/pages/admin/EventFeedbackPage.tsx | 34 ++++++-
frontend/src/services/feedback.service.ts | 13 ++-
6 files changed, 168 insertions(+), 24 deletions(-)
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. */}
+
+
+
+