feat(daten): export als zip, json und csv sowie import eines archivs
Unter Verwaltung -> Daten laedt der Admin alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON oder die Sammlung als CSV - letzteres mit Semikolon und BOM, damit deutsches Excel es ohne Rueckfragen oeffnet. Die CSV steht auch Mitgliedern offen, das vollstaendige Archiv nur Administratoren. Bewusst nicht im Export: Konten, Passwoerter und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner waere ein Risiko und kein Backup - das bleibt ./data. Ebenfalls draussen: Reservierungen und Teilen-Links, die zur jeweiligen Instanz gehoeren. Der Import ersetzt alle Anwendungsdaten in einer Transaktion, statt zu vermischen: Zusammenfuehren zweier Sammlungen braeuchte Konfliktregeln fuer jede Tabelle und waere im Ergebnis nicht vorhersagbar. Deshalb ist er Administratoren vorbehalten und verlangt ein getipptes ERSETZEN. Wunschlisten gehoeren danach dem importierenden Admin und bekommen frische, zunaechst nicht geteilte Links. Pfade aus dem Archiv werden gegen Ausbrueche aus dem Dateiverzeichnis geprueft. 10 Tests decken den Rundweg ab: Export, Loeschen, Import, Ersetzen statt Vermischen, und dass ein fremdes Archiv abgelehnt wird, ohne etwas zu loeschen. Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
buildCollectionCsv,
|
||||
buildExportPayload,
|
||||
buildExportZip,
|
||||
exportFileName,
|
||||
} from '@/lib/export';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { centsToInput } from '@/lib/money';
|
||||
import { listCollectionItems } from '@/lib/queries/collection';
|
||||
import { getCurrentUser } from '@/lib/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
{ key: 'setNumber', label: 'Setnummer' },
|
||||
{ key: 'setName', label: 'Name' },
|
||||
{ key: 'manufacturer', label: 'Hersteller' },
|
||||
{ key: 'theme', label: 'Thema' },
|
||||
{ key: 'year', label: 'Jahr' },
|
||||
{ key: 'pieceCount', label: 'Teile' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'completeness', label: 'Vollständigkeit' },
|
||||
{ key: 'condition', label: 'Zustand' },
|
||||
{ key: 'location', label: 'Standort' },
|
||||
{ key: 'acquiredAt', label: 'Im Besitz seit' },
|
||||
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
|
||||
{ key: 'retailPrice', label: 'UVP (EUR)' },
|
||||
{ key: 'acquiredFrom', label: 'Gekauft bei' },
|
||||
{ key: 'giftedBy', label: 'Geschenkt von' },
|
||||
{ key: 'notes', label: 'Notizen' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Downloads.
|
||||
*
|
||||
* The full archive is for admins - it contains the whole collection. The CSV
|
||||
* of the collection is available to every signed-in member.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ kind: string }> },
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
|
||||
|
||||
const { kind } = await context.params;
|
||||
|
||||
if (kind === 'csv') {
|
||||
const rows = listCollectionItems({}).map((item) => ({
|
||||
setNumber: item.setNumber,
|
||||
setName: item.setName,
|
||||
manufacturer: item.manufacturerName,
|
||||
theme: item.themeName,
|
||||
year: item.year,
|
||||
pieceCount: item.pieceCount,
|
||||
status: statusLabel(item.status),
|
||||
completeness: completenessLabel(item.completeness),
|
||||
condition: conditionLabel(item.condition),
|
||||
location: item.locationName,
|
||||
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
|
||||
acquiredPrice: centsToInput(item.acquiredPriceCents),
|
||||
retailPrice: centsToInput(item.retailPriceCents),
|
||||
acquiredFrom: item.acquiredFrom,
|
||||
giftedBy: item.giftedBy,
|
||||
notes: item.notes,
|
||||
}));
|
||||
|
||||
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
|
||||
}
|
||||
|
||||
if (kind === 'json') {
|
||||
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'zip') {
|
||||
const archive = buildExportZip();
|
||||
return new NextResponse(archive as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': String(archive.byteLength),
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse('Unbekanntes Format', { status: 404 });
|
||||
}
|
||||
Reference in New Issue
Block a user