feat(katalog): set-katalog mit filtern, detailseite und uploads

Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:59:26 +02:00
co-authored by Claude Opus 5
parent af0ae039cb
commit 856800b4e4
29 changed files with 4273 additions and 0 deletions
@@ -0,0 +1,58 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { SetForm } from '@/components/katalog/set-form';
import { getSetById } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Set bearbeiten' };
export default async function SetBearbeitenPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireUser();
const { id } = await params;
const set = getSetById(id);
if (!set) notFound();
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Set bearbeiten</h1>
<p className="text-sm text-muted-foreground">
{set.setNumber} · {set.name}
</p>
</div>
<SetForm
manufacturers={manufacturers}
themes={themes}
set={{
id: set.id,
manufacturerId: set.manufacturerId,
themeId: set.themeId,
setNumber: set.setNumber,
name: set.name,
year: set.year,
pieceCount: set.pieceCount,
minifigCount: set.minifigCount,
ean: set.ean,
ageRange: set.ageRange,
imageUrl: set.imageUrl,
imagePath: set.imagePath,
retailPriceCents: set.retailPriceCents,
notes: set.notes,
}}
/>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section';
import { SetActions } from '@/components/katalog/set-actions';
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatNumber } from '@/lib/format';
import { setImageSrc } from '@/lib/images';
import { formatEuro } from '@/lib/money';
import {
getSetById,
listCollectionItemsForSet,
listInstructionsForSet,
listPhotosForSet,
} from '@/lib/queries/catalog';
import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
}
export default async function SetDetailPage({ params }: Props) {
await requireUser();
const { id } = await params;
const set = getSetById(id);
if (!set) notFound();
const image = setImageSrc(set);
const owned = set.ownedCopies > 0;
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
}));
const facts: Array<[string, string]> = [
['Hersteller', set.manufacturerName],
['Thema', set.themeName ?? '–'],
['Setnummer', set.setNumber],
['Jahr', set.year ? String(set.year) : '–'],
['Teile', formatNumber(set.pieceCount)],
['Minifiguren', formatNumber(set.minifigCount)],
['Alter', set.ageRange ?? '–'],
['EAN', set.ean ?? '–'],
['UVP', formatEuro(set.retailPriceCents)],
];
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
{set.setNumber}
</Badge>
<Badge variant="secondary">{set.manufacturerName}</Badge>
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : (
<Badge variant="outline">Fehlt noch</Badge>
)}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{set.name}</h1>
</div>
<Button asChild variant="outline" size="icon-lg" aria-label="Set bearbeiten">
<Link href={`/katalog/${set.id}/bearbeiten`}>
<Pencil />
</Link>
</Button>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
<div className="overflow-hidden rounded-xl border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
) : (
<div className="flex aspect-square items-center justify-center text-muted-foreground">
<ImageOff className="size-10" aria-hidden />
</div>
)}
</div>
<div className="space-y-4">
<SetActions
setId={set.id}
setName={`${set.setNumber} · ${set.name}`}
owned={owned}
wishlists={listWishlistTargets()}
/>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
{facts.map(([label, value]) => (
<div key={label}>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
))}
</dl>
{set.manufacturerWebsite ? (
<Button asChild variant="link" size="sm" className="px-0">
<a href={set.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
Zum Hersteller
<ExternalLink />
</a>
</Button>
) : null}
{set.notes ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{set.notes}</p>
) : null}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>In der Sammlung</CardTitle>
</CardHeader>
<CardContent>
<CollectionItemsSection items={collectionItems} locations={locations} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Anleitungen</CardTitle>
</CardHeader>
<CardContent>
<InstructionsSection setId={set.id} instructions={listInstructionsForSet(set.id)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fotos</CardTitle>
</CardHeader>
<CardContent>
<PhotosSection setId={set.id} photos={listPhotosForSet(set.id)} />
</CardContent>
</Card>
<WishlistMemberships setId={set.id} />
</div>
);
}
function WishlistMemberships({ setId }: { setId: string }) {
const lists = listWishlistsForSet(setId);
if (lists.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle>Auf diesen Wunschlisten</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{lists.map((list) => (
<Button key={list.id} asChild variant="outline" size="sm">
<Link href={`/wunschlisten/${list.id}`}>
<Gift />
{list.title}
</Link>
</Button>
))}
</CardContent>
</Card>
);
}
+291
View File
@@ -0,0 +1,291 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db';
import { instruction, photo, set } from '@/db/schema';
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads';
import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
import { z } from 'zod';
function now() {
return new Date();
}
function revalidateSet(id?: string) {
revalidatePath('/katalog');
revalidatePath('/sammlung');
if (id) revalidatePath(`/katalog/${id}`);
}
function fileFrom(formData: FormData, field: string): File | null {
const value = formData.get(field);
return value instanceof File && value.size > 0 ? value : null;
}
// ---------------------------------------------------------------------------
// Sets
// ---------------------------------------------------------------------------
export async function createSet(formData: FormData): Promise<ActionResult<{ id: string }>> {
await requireUser();
const parsed = parseForm(setSchema, formToObject(formData));
if (!parsed.ok) return parsed;
const clash = db
.select({ id: set.id })
.from(set)
.where(
and(
eq(set.manufacturerId, parsed.data.manufacturerId),
eq(set.setNumber, parsed.data.setNumber),
),
)
.get();
if (clash) {
return actionError('Dieses Set gibt es beim gewählten Hersteller schon.');
}
const id = crypto.randomUUID();
let imagePath: string | null = null;
const image = fileFrom(formData, 'image');
if (image) {
try {
imagePath = (await storeImage(image, 'set-image', id)).filePath;
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
}
db.insert(set)
.values({
id,
...parsed.data,
imagePath,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateSet(id);
return actionData({ id });
}
export async function updateSet(id: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(setSchema, formToObject(formData));
if (!parsed.ok) return parsed;
const existing = db.select().from(set).where(eq(set.id, id)).get();
if (!existing) return actionError('Das Set gibt es nicht mehr.');
const clash = db
.select({ id: set.id })
.from(set)
.where(
and(
eq(set.manufacturerId, parsed.data.manufacturerId),
eq(set.setNumber, parsed.data.setNumber),
ne(set.id, id),
),
)
.get();
if (clash) return actionError('Ein anderes Set des Herstellers hat diese Nummer bereits.');
let imagePath = existing.imagePath;
const image = fileFrom(formData, 'image');
if (image) {
try {
const stored = await storeImage(image, 'set-image', id);
await deleteStoredFile(existing.imagePath);
imagePath = stored.filePath;
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
} else if (formData.get('removeImage') === 'on') {
await deleteStoredFile(existing.imagePath);
imagePath = null;
}
db.update(set)
.set({ ...parsed.data, imagePath, updatedAt: now() })
.where(eq(set.id, id))
.run();
revalidateSet(id);
return actionOk();
}
export async function deleteSet(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(set).where(eq(set.id, id)).get();
if (!existing) return actionOk();
const files = [
existing.imagePath,
...db
.select({ path: photo.filePath, thumb: photo.thumbnailPath })
.from(photo)
.where(eq(photo.setId, id))
.all()
.flatMap((row) => [row.path, row.thumb]),
...db
.select({ path: instruction.filePath })
.from(instruction)
.where(eq(instruction.setId, id))
.all()
.map((row) => row.path),
];
// Rows go first: if a file is already gone the delete still has to succeed.
db.delete(set).where(eq(set.id, id)).run();
await Promise.all(files.map((file) => deleteStoredFile(file)));
revalidateSet(id);
return actionOk();
}
// ---------------------------------------------------------------------------
// Instructions
// ---------------------------------------------------------------------------
const instructionUrlSchema = z.object({
label: optionalText(80),
url: optionalUrl,
});
export async function addInstructionPdf(setId: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const file = fileFrom(formData, 'file');
if (!file) return actionError('Bitte wähle eine PDF-Datei aus.');
const parsed = parseForm(z.object({ label: optionalText(80) }), formToObject(formData));
if (!parsed.ok) return parsed;
try {
const stored = await storePdf(file, setId);
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'pdf_upload',
filePath: stored.filePath,
fileSizeBytes: stored.fileSizeBytes,
label: parsed.data.label,
sortOrder: nextInstructionSortOrder(setId),
createdAt: now(),
})
.run();
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
revalidateSet(setId);
return actionOk();
}
export async function addInstructionUrl(setId: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(instructionUrlSchema, input);
if (!parsed.ok) return parsed;
if (!parsed.data.url) return actionError('Bitte gib die Adresse der Anleitung an.');
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'external_url',
url: parsed.data.url,
label: parsed.data.label,
sortOrder: nextInstructionSortOrder(setId),
createdAt: now(),
})
.run();
revalidateSet(setId);
return actionOk();
}
export async function deleteInstruction(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(instruction).where(eq(instruction.id, id)).get();
if (!existing) return actionOk();
db.delete(instruction).where(eq(instruction.id, id)).run();
await deleteStoredFile(existing.filePath);
revalidateSet(existing.setId);
return actionOk();
}
function nextInstructionSortOrder(setId: string): number {
const rows = db
.select({ sortOrder: instruction.sortOrder })
.from(instruction)
.where(eq(instruction.setId, setId))
.all();
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
}
// ---------------------------------------------------------------------------
// Photos
// ---------------------------------------------------------------------------
export async function addSetPhoto(setId: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const file = fileFrom(formData, 'file');
if (!file) return actionError('Bitte wähle ein Bild aus.');
const parsed = parseForm(z.object({ caption: optionalText(160) }), formToObject(formData));
if (!parsed.ok) return parsed;
try {
const stored = await storeImage(file, 'photo', setId);
db.insert(photo)
.values({
id: crypto.randomUUID(),
setId,
filePath: stored.filePath,
thumbnailPath: stored.thumbnailPath,
caption: parsed.data.caption,
sortOrder: 0,
createdAt: now(),
})
.run();
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
revalidateSet(setId);
return actionOk();
}
export async function deletePhoto(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(photo).where(eq(photo.id, id)).get();
if (!existing) return actionOk();
db.delete(photo).where(eq(photo.id, id)).run();
await Promise.all([
deleteStoredFile(existing.filePath),
deleteStoredFile(existing.thumbnailPath),
]);
if (existing.setId) revalidateSet(existing.setId);
return actionOk();
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export default async function NeuesSetPage({ searchParams }: { searchParams: SearchParams }) {
await requireUser();
const params = await searchParams;
const ean = typeof params.ean === 'string' ? params.ean : undefined;
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
if (manufacturers.length === 0) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
<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
was auch immer im Kinderzimmer steht.
</p>
<Button asChild size="lg">
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
<p className="text-sm text-muted-foreground">
Ein Set im Katalog heißt noch nicht, dass es da ist – das legst du danach über „Zur
Sammlung hinzufügen“ fest.
</p>
</div>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Katalog' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export default async function KatalogPage({ searchParams }: { searchParams: SearchParams }) {
await requireUser();
const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz));
const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1;
const filters = {
q: single(params.q),
manufacturerId: single(params.hersteller),
themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership,
page,
};
const { rows, total } = listSets(filters);
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
const wishlists = listWishlistTargets();
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{ownership === 'fehlt' ? 'Fehlende Sets' : 'Katalog'}
</h1>
<p className="text-sm text-muted-foreground">
{ownership === 'fehlt'
? 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p>
</div>
<Button asChild size="lg" className="shrink-0">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</div>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground">
{pluralize(total, 'Set', 'Sets')}
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p>
<CatalogGrid sets={rows} wishlists={wishlists} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use server';
import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, set } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { collectionItemSchema } from '@/lib/validation';
import type { Condition } from '@/db/types';
function now() {
return new Date();
}
function revalidateCollection(setId?: string) {
revalidatePath('/sammlung');
revalidatePath('/katalog');
revalidatePath('/wunschlisten');
revalidatePath('/');
if (setId) revalidatePath(`/katalog/${setId}`);
}
/**
* Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later.
*/
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
if (!exists) return actionError('Das Set gibt es nicht mehr.');
const id = crypto.randomUUID();
db.insert(collectionItem)
.values({
id,
setId,
status: 'owned',
completeness: 'unknown',
createdAt: now(),
updatedAt: now(),
})
.run();
// A wish that just came true is marked as fulfilled right away.
reconcileFulfilledWishlistItems();
revalidateCollection(setId);
return actionData({ id });
}
export async function updateCollectionItem(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectionItemSchema, input);
if (!parsed.ok) return parsed;
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
if (!existing) return actionError('Dieser Sammlungseintrag gibt es nicht mehr.');
db.update(collectionItem)
.set({
status: parsed.data.status,
completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null,
locationId: parsed.data.locationId,
acquiredAt: parsed.data.acquiredAt,
acquiredPriceCents: parsed.data.acquiredPriceCents,
acquiredFrom: parsed.data.acquiredFrom,
giftedBy: parsed.data.giftedBy,
lentOutTo: parsed.data.lentOutTo,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collectionItem.id, id))
.run();
// Selling the last copy puts the set back into "fehlt" - and back onto the
// wishlists as an open wish.
reconcileFulfilledWishlistItems();
revalidateCollection(existing.setId);
return actionOk();
}
export async function deleteCollectionItem(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
if (!existing) return actionOk();
db.delete(collectionItem).where(eq(collectionItem.id, id)).run();
reconcileFulfilledWishlistItems();
revalidateCollection(existing.setId);
return actionOk();
}
+254
View File
@@ -0,0 +1,254 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { set, wishlist, wishlistItem } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
import {
findExistingWishlistSetIds,
getWishlist,
nextWishlistSortOrder,
} from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { createToken } from '@/lib/tokens';
import { describeBulkAdd, planBulkAdd } from '@/lib/wishlist-logic';
import {
addSetsToWishlistSchema,
wishlistItemSchema,
wishlistSchema,
} from '@/lib/validation';
function now() {
return new Date();
}
function revalidateWishlist(id?: string) {
revalidatePath('/wunschlisten');
revalidatePath('/katalog');
if (id) revalidatePath(`/wunschlisten/${id}`);
}
/** Every wishlist action first proves the current user owns the list. */
async function requireOwnedWishlist(id: string) {
const user = await requireUser();
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) return null;
return list;
}
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------
export async function createWishlist(input: unknown): Promise<ActionResult<{ id: string }>> {
const user = await requireUser();
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
const id = crypto.randomUUID();
db.insert(wishlist)
.values({
id,
ownerUserId: user.id,
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
shareToken: createToken(),
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateWishlist(id);
return actionData({ id });
}
export async function updateWishlist(id: string, input: unknown): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
db.update(wishlist)
.set({
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
updatedAt: now(),
})
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionOk();
}
export async function deleteWishlist(id: string): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.delete(wishlist).where(eq(wishlist.id, id)).run();
revalidateWishlist(id);
return actionOk();
}
/**
* Issues a fresh share token. The old link stops working immediately - the way
* out when a list was shared with the wrong people.
*/
export async function rotateShareToken(id: string): Promise<ActionResult<{ token: string }>> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const token = createToken();
db.update(wishlist)
.set({ shareToken: token, updatedAt: now() })
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionData({ token });
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
/**
* The central path from "fehlende Sets" onto a wishlist.
*
* Sets that are already on the list are skipped and named in the result, and
* so are sets that are already in the collection. The unique index on
* (wishlist_id, set_id) makes that a guarantee rather than a hope.
*/
export async function addSetsToWishlist(
input: unknown,
): Promise<ActionResult<{ added: number; message: string }>> {
await requireUser();
const parsed = parseForm(addSetsToWishlistSchema, input);
if (!parsed.ok) return parsed;
const list = await requireOwnedWishlist(parsed.data.wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const setIds = parsed.data.setIds;
const known = db
.select({
id: set.id,
name: set.name,
setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
})
.from(set)
.where(inArray(set.id, setIds))
.all();
if (known.length === 0) return actionError('Die ausgewählten Sets gibt es nicht mehr.');
const names = new Map(known.map((row) => [row.id, `${row.setNumber} ${row.name}`]));
const ownedSetIds = new Set(known.filter((row) => row.ownedCopies > 0).map((row) => row.id));
const existing = findExistingWishlistSetIds(list.id, setIds);
const plan = planBulkAdd(
setIds.filter((id) => names.has(id)),
existing,
ownedSetIds,
);
if (plan.toAdd.length > 0) {
let sortOrder = nextWishlistSortOrder(list.id);
db.insert(wishlistItem)
.values(
plan.toAdd.map((setId) => ({
id: crypto.randomUUID(),
wishlistId: list.id,
setId,
priority: parsed.data.priority,
sortOrder: sortOrder++,
createdAt: now(),
})),
)
// Belt and braces: a concurrent add must not blow up the whole batch.
.onConflictDoNothing()
.run();
}
revalidateWishlist(list.id);
return actionData({ added: plan.toAdd.length, message: describeBulkAdd(plan, names) });
}
export async function updateWishlistItem(itemId: string, input: unknown): Promise<ActionResult> {
const user = await requireUser();
const parsed = parseForm(wishlistItemSchema, input);
if (!parsed.ok) return parsed;
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionError('Dieser Eintrag gehört nicht zu deinen Wunschlisten.');
db.update(wishlistItem)
.set({ priority: parsed.data.priority, note: parsed.data.note })
.where(eq(wishlistItem.id, itemId))
.run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
export async function removeWishlistItem(itemId: string): Promise<ActionResult> {
const user = await requireUser();
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionOk();
db.delete(wishlistItem).where(eq(wishlistItem.id, itemId)).run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
/** Persists the order after drag and drop or the move buttons. */
export async function reorderWishlistItems(
wishlistId: string,
orderedItemIds: string[],
): Promise<ActionResult> {
const list = await requireOwnedWishlist(wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.transaction((tx) => {
orderedItemIds.forEach((itemId, index) => {
tx.update(wishlistItem)
.set({ sortOrder: index })
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, wishlistId)))
.run();
});
});
revalidateWishlist(wishlistId);
return actionOk();
}