feat(import): lego-sets ueber die rebrickable-api uebernehmen

Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr,
Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal
gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei
Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen
serverseitig; der Browser spricht nie mit rebrickable.com.

Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet,
mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert.

Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern,
legt der Import einen externen Link auf die LEGO-Serviceseite zur
Setnummer an.

Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein
"BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid
URL" statt zum Standardwert.

14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und
alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk).

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 07:12:22 +02:00
co-authored by Claude Opus 5
parent 52d69e5654
commit 4e3cbb81ce
13 changed files with 813 additions and 6 deletions
+237
View File
@@ -0,0 +1,237 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { instruction, manufacturer, set, theme } from '@/db/schema';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import {
fetchImageBytes,
fetchMinifigCount,
fetchSet,
fetchTheme,
isRebrickableEnabled,
legoInstructionsUrl,
RebrickableError,
} from '@/lib/rebrickable';
import { requireUser } from '@/lib/session';
import { uniqueSlug } from '@/lib/slug';
import { storeImageBytes, UploadError } from '@/lib/uploads';
/**
* Import of a Lego set from Rebrickable.
*
* Runs entirely on the server, creates the manufacturer and the theme if they
* are missing, and stores the picture locally so the catalog keeps working
* when Rebrickable changes it.
*/
const LEGO_SLUG = 'lego';
function now() {
return new Date();
}
/** Returns the Lego manufacturer, creating it on first import. */
function ensureLegoManufacturer(): string {
const existing = db
.select({ id: manufacturer.id })
.from(manufacturer)
.where(eq(manufacturer.slug, LEGO_SLUG))
.get();
if (existing) return existing.id;
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
const id = crypto.randomUUID();
db.insert(manufacturer)
.values({
id,
name: 'Lego',
slug: uniqueSlug('Lego', taken.map((row) => row.slug)),
website: 'https://www.lego.com/de-de',
createdAt: now(),
updatedAt: now(),
})
.run();
return id;
}
/**
* Maps a Rebrickable theme onto a local one, creating it (and its parent) when
* needed. Rebrickable nests themes deeply; one level of parent is enough to
* keep "Technic > Cars" readable without importing the whole tree.
*/
async function ensureTheme(manufacturerId: string, themeId: number | null): Promise<string | null> {
if (themeId === null) return null;
const remote = await fetchTheme(themeId);
if (!remote) return null;
const existing = db
.select({ id: theme.id })
.from(theme)
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name)))
.get();
if (existing) return existing.id;
let parentThemeId: string | null = null;
if (remote.parentId !== null) {
const parent = await fetchTheme(remote.parentId);
if (parent) {
const existingParent = db
.select({ id: theme.id })
.from(theme)
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name)))
.get();
if (existingParent) {
parentThemeId = existingParent.id;
} else {
parentThemeId = crypto.randomUUID();
db.insert(theme)
.values({
id: parentThemeId,
manufacturerId,
name: parent.name,
createdAt: now(),
updatedAt: now(),
})
.run();
}
}
}
const id = crypto.randomUUID();
db.insert(theme)
.values({
id,
manufacturerId,
name: remote.name,
parentThemeId,
createdAt: now(),
updatedAt: now(),
})
.run();
return id;
}
export type ImportResult = {
setId: string;
setNumber: string;
name: string;
imported: {
image: boolean;
theme: boolean;
minifigs: boolean;
};
};
export async function importSetFromRebrickable(
rawSetNumber: unknown,
): Promise<ActionResult<ImportResult>> {
await requireUser();
if (!isRebrickableEnabled()) {
return actionError(
'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.',
);
}
const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : '';
if (!setNumber) return actionError('Bitte gib eine Setnummer ein.');
if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.');
let remote;
try {
remote = await fetchSet(setNumber);
} catch (error) {
if (error instanceof RebrickableError) return actionError(error.message);
throw error;
}
if (!remote) {
return actionError(
`Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`,
);
}
const manufacturerId = ensureLegoManufacturer();
const duplicate = db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber)))
.get();
if (duplicate) {
return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`);
}
const themeId = await ensureTheme(manufacturerId, remote.themeId);
const minifigCount = await fetchMinifigCount(remote.setNumber);
const setId = crypto.randomUUID();
// Cache the picture locally; a failed download must not fail the import.
let imagePath: string | null = null;
if (remote.imageUrl) {
const bytes = await fetchImageBytes(remote.imageUrl);
if (bytes) {
try {
imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath;
} catch (error) {
if (!(error instanceof UploadError)) throw error;
console.error('[import] Bild konnte nicht gespeichert werden', error);
}
}
}
db.insert(set)
.values({
id: setId,
manufacturerId,
themeId,
setNumber: remote.setNumber,
name: remote.name,
year: remote.year,
pieceCount: remote.pieceCount,
minifigCount,
imagePath,
// Keep the source URL as a fallback if the local file is ever lost.
imageUrl: imagePath ? null : remote.imageUrl,
externalIds: { rebrickable: `${remote.setNumber}-1` },
createdAt: now(),
updatedAt: now(),
})
.run();
// Rebrickable does not serve instruction files, so this points at Lego's
// own service page for the set number.
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'external_url',
url: legoInstructionsUrl(remote.setNumber),
label: 'Bauanleitung bei LEGO',
sortOrder: 0,
createdAt: now(),
})
.run();
revalidatePath('/katalog');
revalidatePath('/verwaltung/themen');
return actionData({
setId,
setNumber: remote.setNumber,
name: remote.name,
imported: {
image: imagePath !== null,
theme: themeId !== null,
minifigs: minifigCount !== null,
},
});
}
+19 -2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' };
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId,
}));
const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) {
return (
<div className="space-y-4">
<div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
{/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} />
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
</p>
<Button asChild size="lg">
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest.
</p>
</div>
<RebrickableImport enabled={importEnabled} />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
oder von Hand
<span className="h-px flex-1 bg-border" />
</div>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
</div>
);