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:
Paul Nothaft
2026-06-18 23:08:55 +02:00
parent 78c8e9d9f9
commit fabd67aecd
6 changed files with 168 additions and 24 deletions
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+29 -5
View File
@@ -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 = () => {
</p>
</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
variant="outline"
size="sm"
+11 -2
View File
@@ -136,9 +136,18 @@ class FeedbackService {
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`, {
params: { format },
params: { format, shape },
responseType: format === 'csv' ? 'blob' : 'json'
});
return response.data;