feat(feedback): export shape toggle — per-action vs per-guest pivot (#640 part E)
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)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user