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:
@@ -315,15 +315,21 @@ router.get('/events/:eventId/feedback/export',
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { format = 'json' } = req.query;
|
const { format = 'json', shape = 'long' } = req.query;
|
||||||
|
|
||||||
const feedback = await feedbackService.exportEventFeedback(eventId);
|
// 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') {
|
if (format === 'csv') {
|
||||||
// Convert to CSV
|
|
||||||
const csv = convertToCSV(feedback);
|
const csv = convertToCSV(feedback);
|
||||||
|
const fileSuffix = isPivot ? 'pivot' : 'long';
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
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);
|
res.send(csv);
|
||||||
} else {
|
} else {
|
||||||
res.json(feedback);
|
res.json(feedback);
|
||||||
@@ -428,7 +434,11 @@ 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) {
|
function convertToCSV(data) {
|
||||||
if (!data || data.length === 0) return '';
|
if (!data || data.length === 0) return '';
|
||||||
|
|
||||||
@@ -438,11 +448,13 @@ function convertToCSV(data) {
|
|||||||
const csvRows = data.map(row => {
|
const csvRows = data.map(row => {
|
||||||
return headers.map(header => {
|
return headers.map(header => {
|
||||||
const value = row[header];
|
const value = row[header];
|
||||||
// Escape quotes and wrap in quotes if contains comma
|
if (value === null || value === undefined) return '';
|
||||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
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.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return value || '';
|
return value;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
async exportEventFeedback(eventId) {
|
||||||
try {
|
try {
|
||||||
@@ -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
|
* Get filtered photos based on feedback criteria
|
||||||
* @param {number} eventId - Event ID
|
* @param {number} eventId - Event ID
|
||||||
|
|||||||
@@ -3092,7 +3092,10 @@
|
|||||||
"onPhoto": "Auf Foto",
|
"onPhoto": "Auf Foto",
|
||||||
"showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen",
|
"showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen",
|
||||||
"showAll_other": "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": {
|
"filter": {
|
||||||
"feedbackFilters": "Feedback-Filter",
|
"feedbackFilters": "Feedback-Filter",
|
||||||
|
|||||||
@@ -3113,7 +3113,10 @@
|
|||||||
"onPhoto": "On photo",
|
"onPhoto": "On photo",
|
||||||
"showAll_one": "Show all {{count}} pending comments",
|
"showAll_one": "Show all {{count}} pending comments",
|
||||||
"showAll_other": "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": {
|
"filter": {
|
||||||
"feedbackFilters": "Feedback Filters",
|
"feedbackFilters": "Feedback Filters",
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 20
|
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
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
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') => {
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
try {
|
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') {
|
if (format === 'csv') {
|
||||||
const blob = new Blob([data], { type: 'text/csv' });
|
const blob = new Blob([data], { type: 'text/csv' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `feedback-${event?.slug || id}.csv`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
} else {
|
} else {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `feedback-${event?.slug || id}.json`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
toast.success(t('feedback.exported', 'Feedback exported'));
|
toast.success(t('feedback.exported', 'Feedback exported'));
|
||||||
@@ -160,7 +168,23 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-end">
|
||||||
|
{/* Shape selector (#640 #6). Long is the existing per-action shape;
|
||||||
|
pivot is per-(photo, guest) for spreadsheet pivot tables. */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-neutral-500 dark:text-neutral-400" htmlFor="feedback-export-shape">
|
||||||
|
{t('feedback.exportShapeLabel', 'Shape')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="feedback-export-shape"
|
||||||
|
value={exportShape}
|
||||||
|
onChange={(e) => setExportShape(e.target.value as 'long' | 'pivot')}
|
||||||
|
className="text-sm px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<option value="long">{t('feedback.exportShapeLong', 'Per-action (long)')}</option>
|
||||||
|
<option value="pivot">{t('feedback.exportShapePivot', 'Per-guest (pivot)')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -136,9 +136,18 @@ class FeedbackService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportEventFeedback(eventId: string, format: 'json' | 'csv' = 'json') {
|
// `shape` defaults to 'long' (one row per feedback action) for backward
|
||||||
|
// compatibility with anyone scripting against this endpoint. 'pivot' (per
|
||||||
|
// #640 #6) returns one row per (photo, guest_identifier) with boolean
|
||||||
|
// is_favorited / is_liked plus star_rating + comment. Hidden-by-moderator
|
||||||
|
// rows are excluded from the pivot.
|
||||||
|
async exportEventFeedback(
|
||||||
|
eventId: string,
|
||||||
|
format: 'json' | 'csv' = 'json',
|
||||||
|
shape: 'long' | 'pivot' = 'long',
|
||||||
|
) {
|
||||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
||||||
params: { format },
|
params: { format, shape },
|
||||||
responseType: format === 'csv' ? 'blob' : 'json'
|
responseType: format === 'csv' ? 'blob' : 'json'
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user