feat(sammler): mehrere sammler mit eigener sammlung und wunschliste
Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er* das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste. Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so einfach wie vorher. Erst der zweite Sammler schaltet all das frei. Die Besitzlogik liegt weiterhin an genau einer Stelle (src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste: ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher. Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler. Der Seed uebernimmt ihn statt einen dritten anzulegen. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, collector, wishlist } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectorSchema } from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateEverything() {
|
||||
// A collector shows up in the catalog, the collection and the wishlists.
|
||||
revalidatePath('/', 'layout');
|
||||
}
|
||||
|
||||
export async function createCollector(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const [row] = db
|
||||
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
|
||||
.from(collector)
|
||||
.all();
|
||||
|
||||
db.insert(collector)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
sortOrder: row?.next ?? 0,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.update(collector)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a collector would take their whole collection with it, so it is
|
||||
* only allowed while nothing is attached - and never for the last one.
|
||||
*/
|
||||
export async function deleteCollector(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const remaining = db.select({ id: collector.id }).from(collector).all();
|
||||
if (remaining.length <= 1) {
|
||||
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
const owned = db
|
||||
.select({ id: collectionItem.id })
|
||||
.from(collectionItem)
|
||||
.where(eq(collectionItem.collectorId, id))
|
||||
.get();
|
||||
if (owned) {
|
||||
return actionError(
|
||||
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
|
||||
);
|
||||
}
|
||||
|
||||
const lists = db
|
||||
.select({ id: wishlist.id })
|
||||
.from(wishlist)
|
||||
.where(eq(wishlist.collectorId, id))
|
||||
.get();
|
||||
if (lists) {
|
||||
return actionError(
|
||||
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(collector).where(eq(collector.id, id)).run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Moves every owned copy of one collector over to another. */
|
||||
export async function reassignCollectionItems(
|
||||
fromCollectorId: string,
|
||||
toCollectorId: string,
|
||||
): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
if (fromCollectorId === toCollectorId) return actionOk();
|
||||
|
||||
const target = db
|
||||
.select({ id: collector.id })
|
||||
.from(collector)
|
||||
.where(eq(collector.id, toCollectorId))
|
||||
.get();
|
||||
if (!target) return actionError('Diesen Sammler gibt es nicht.');
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({ collectorId: toCollectorId, updatedAt: now() })
|
||||
.where(eq(collectionItem.collectorId, fromCollectorId))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Reorders the collectors; the first one is the default for new entries. */
|
||||
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
db.transaction((tx) => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
tx.update(collector)
|
||||
.set({ sortOrder: index, updatedAt: now() })
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
});
|
||||
});
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Every collector, ordered - for the selects in the forms. */
|
||||
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
|
||||
await requireUser();
|
||||
|
||||
return db
|
||||
.select({ id: collector.id, name: collector.name })
|
||||
.from(collector)
|
||||
.orderBy(asc(collector.sortOrder), asc(collector.name))
|
||||
.all();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sammler' };
|
||||
|
||||
export default async function SammlerPage() {
|
||||
await requireUser();
|
||||
|
||||
const collectors = listCollectors();
|
||||
const rows: CollectorRowItem[] = collectors.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [
|
||||
pluralize(row.itemCount, 'Set', 'Sets'),
|
||||
COLLECTOR_COLOR_LABELS[row.color],
|
||||
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
color: row.color,
|
||||
birthday: row.birthday,
|
||||
notes: row.notes,
|
||||
itemCount: row.itemCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
|
||||
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
|
||||
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
|
||||
</p>
|
||||
<CollectorList collectors={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user