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:
@@ -14,6 +14,8 @@ export type CatalogFilters = {
|
||||
ownership?: OwnershipFilter;
|
||||
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
|
||||
excludeWishlistId?: string;
|
||||
/** Scopes "owned" and "on a wishlist" to one collector. */
|
||||
collectorId?: string;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
||||
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
|
||||
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
|
||||
|
||||
const owned = ownedCopiesSql(set.id);
|
||||
const owned = ownedCopiesSql(set.id, filters.collectorId);
|
||||
switch (filters.ownership) {
|
||||
case 'sammlung':
|
||||
conditions.push(sql`${owned} > 0`);
|
||||
@@ -61,7 +63,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
||||
conditions.push(sql`${owned} = 0`);
|
||||
break;
|
||||
case 'wunschliste':
|
||||
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
|
||||
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -95,8 +97,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
manufacturerName: manufacturer.name,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
@@ -126,7 +128,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
export function getSetById(id: string) {
|
||||
export function getSetById(id: string, collectorId?: string) {
|
||||
const row = db
|
||||
.select({
|
||||
id: set.id,
|
||||
@@ -148,8 +150,8 @@ export function getSetById(id: string) {
|
||||
notes: set.notes,
|
||||
createdAt: set.createdAt,
|
||||
updatedAt: set.updatedAt,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import 'server-only';
|
||||
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
|
||||
import { SOLD_STATUS } from '@/lib/ownership';
|
||||
import {
|
||||
collectionItem,
|
||||
collector,
|
||||
location,
|
||||
manufacturer,
|
||||
missingPart,
|
||||
set,
|
||||
theme,
|
||||
} from '@/db/schema';
|
||||
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
|
||||
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||
|
||||
export type CollectionFilters = {
|
||||
@@ -11,6 +19,7 @@ export type CollectionFilters = {
|
||||
completeness?: Completeness;
|
||||
locationId?: string;
|
||||
manufacturerId?: string;
|
||||
collectorId?: string;
|
||||
};
|
||||
|
||||
export type CollectionRow = {
|
||||
@@ -21,6 +30,9 @@ export type CollectionRow = {
|
||||
condition: Condition | null;
|
||||
locationId: string | null;
|
||||
locationName: string | null;
|
||||
collectorId: string | null;
|
||||
collectorName: string | null;
|
||||
collectorColor: string | null;
|
||||
acquiredAt: Date | null;
|
||||
acquiredPriceCents: number | null;
|
||||
acquiredFrom: string | null;
|
||||
@@ -51,6 +63,7 @@ function buildWhere(filters: CollectionFilters): SQL | undefined {
|
||||
if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
|
||||
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
|
||||
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
|
||||
if (filters.collectorId) conditions.push(eq(collectionItem.collectorId, filters.collectorId));
|
||||
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
||||
condition: collectionItem.condition,
|
||||
locationId: collectionItem.locationId,
|
||||
locationName: location.name,
|
||||
collectorId: collectionItem.collectorId,
|
||||
collectorName: collector.name,
|
||||
collectorColor: collector.color,
|
||||
acquiredAt: collectionItem.acquiredAt,
|
||||
acquiredPriceCents: collectionItem.acquiredPriceCents,
|
||||
acquiredFrom: collectionItem.acquiredFrom,
|
||||
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||
.leftJoin(location, eq(location.id, collectionItem.locationId))
|
||||
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
|
||||
.where(buildWhere(filters))
|
||||
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
||||
.all();
|
||||
@@ -104,8 +121,13 @@ export type CollectionStats = {
|
||||
* The numbers on top of the collection page. Sold copies are history and do
|
||||
* not count anywhere - same rule as everywhere else in the app.
|
||||
*/
|
||||
export function getCollectionStats(): CollectionStats {
|
||||
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
||||
export function getCollectionStats(collectorId?: string): CollectionStats {
|
||||
const active = collectorId
|
||||
? and(
|
||||
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
|
||||
eq(collectionItem.collectorId, collectorId),
|
||||
)
|
||||
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
||||
|
||||
const [totals] = db
|
||||
.select({
|
||||
@@ -144,16 +166,11 @@ export function getCollectionStats(): CollectionStats {
|
||||
}
|
||||
|
||||
/** Number of catalog sets that are not in the collection - the "fehlt" count. */
|
||||
export function countMissingSets(): number {
|
||||
export function countMissingSets(collectorId?: string): number {
|
||||
const [row] = db
|
||||
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
||||
.from(set)
|
||||
.where(
|
||||
sql`not exists (
|
||||
select 1 from collection_item ci
|
||||
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
|
||||
)`,
|
||||
)
|
||||
.where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
|
||||
.all();
|
||||
|
||||
return row?.total ?? 0;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'server-only';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, collector } from '@/db/schema';
|
||||
import { SOLD_STATUS } from '@/lib/ownership';
|
||||
|
||||
export type CollectorRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
birthday: Date | null;
|
||||
notes: string | null;
|
||||
sortOrder: number;
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
export function listCollectors(): CollectorRow[] {
|
||||
const itemCount = sql<number>`(
|
||||
select count(*) from collection_item ci
|
||||
where ci.collector_id = ${collector.id} and ci.status <> ${SOLD_STATUS}
|
||||
)`.mapWith(Number);
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: collector.id,
|
||||
name: collector.name,
|
||||
color: collector.color,
|
||||
birthday: collector.birthday,
|
||||
notes: collector.notes,
|
||||
sortOrder: collector.sortOrder,
|
||||
itemCount,
|
||||
})
|
||||
.from(collector)
|
||||
.orderBy(asc(collector.sortOrder), asc(collector.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getCollector(id: string) {
|
||||
return db.select().from(collector).where(eq(collector.id, id)).get() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collector a new collection item belongs to when the user did not pick
|
||||
* one - which is every time there is only a single collector.
|
||||
*/
|
||||
export function defaultCollectorId(): string | null {
|
||||
const row = db
|
||||
.select({ id: collector.id })
|
||||
.from(collector)
|
||||
.orderBy(asc(collector.sortOrder), asc(collector.name))
|
||||
.get();
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole multi-collector idea only becomes visible once a second collector
|
||||
* exists. Until then the UI stays exactly as simple as it was.
|
||||
*/
|
||||
export function hasMultipleCollectors(): boolean {
|
||||
const [row] = db
|
||||
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
||||
.from(collector)
|
||||
.all();
|
||||
return (row?.total ?? 0) > 1;
|
||||
}
|
||||
|
||||
/** Reads the collector from a URL parameter, ignoring anything unknown. */
|
||||
export function resolveCollectorId(value: string | undefined | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const row = db.select({ id: collector.id }).from(collector).where(eq(collector.id, value)).get();
|
||||
return row?.id;
|
||||
}
|
||||
|
||||
/** Owned copies per collector - used by the collector management page. */
|
||||
export function countItemsPerCollector(): Map<string, number> {
|
||||
const rows = db
|
||||
.select({
|
||||
collectorId: collectionItem.collectorId,
|
||||
total: sql<number>`count(*)`.mapWith(Number),
|
||||
})
|
||||
.from(collectionItem)
|
||||
.where(sql`${collectionItem.status} <> ${SOLD_STATUS}`)
|
||||
.groupBy(collectionItem.collectorId)
|
||||
.all();
|
||||
|
||||
return new Map(
|
||||
rows.flatMap((row) => (row.collectorId ? [[row.collectorId, row.total] as const] : [])),
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'server-only';
|
||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
|
||||
|
||||
/** Short list for the "auf welche Wunschliste?" dialogs. */
|
||||
export function listWishlistTargets() {
|
||||
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
|
||||
isPublic: wishlist.isPublic,
|
||||
surpriseMode: wishlist.surpriseMode,
|
||||
shareToken: wishlist.shareToken,
|
||||
collectorId: wishlist.collectorId,
|
||||
collectorName: collector.name,
|
||||
collectorColor: collector.color,
|
||||
createdAt: wishlist.createdAt,
|
||||
itemCount: count(wishlistItem.id),
|
||||
openCount: openItems,
|
||||
})
|
||||
.from(wishlist)
|
||||
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
||||
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
|
||||
.where(eq(wishlist.ownerUserId, ownerUserId))
|
||||
.groupBy(wishlist.id)
|
||||
.orderBy(asc(wishlist.title))
|
||||
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
|
||||
ownedCopies: number;
|
||||
};
|
||||
|
||||
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
||||
export function listWishlistItems(
|
||||
wishlistId: string,
|
||||
collectorId?: string | null,
|
||||
): WishlistItemRow[] {
|
||||
return db
|
||||
.select({
|
||||
id: wishlistItem.id,
|
||||
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
||||
manufacturerName: manufacturer.name,
|
||||
manufacturerWebsite: manufacturer.website,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||
})
|
||||
.from(wishlistItem)
|
||||
.innerJoin(set, eq(set.id, wishlistItem.setId))
|
||||
@@ -145,17 +152,28 @@ export function listWishlistsForSet(setId: string) {
|
||||
* Marks wishlist entries as fulfilled once the set is in the collection, and
|
||||
* clears the flag again if the copy was sold. Called after every change to the
|
||||
* collection, so the lists stay honest without a background job.
|
||||
*
|
||||
* With several collectors the question is per list: a wish on Max's list is
|
||||
* fulfilled when *Max* owns the set, not when his sister does. A list without
|
||||
* a collector falls back to "anyone here owns it".
|
||||
*/
|
||||
export function reconcileFulfilledWishlistItems(): void {
|
||||
const owned = ownedCopiesSql(wishlistItem.setId);
|
||||
const ownedForThisList = sql`exists (
|
||||
select 1
|
||||
from collection_item ci
|
||||
join wishlist w on w.id = ${wishlistItem.wishlistId}
|
||||
where ci.set_id = ${wishlistItem.setId}
|
||||
and ci.status <> ${SOLD_STATUS}
|
||||
and (w.collector_id is null or ci.collector_id = w.collector_id)
|
||||
)`;
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ fulfilledAt: new Date() })
|
||||
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
|
||||
.where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
|
||||
.run();
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ fulfilledAt: null })
|
||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
|
||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
|
||||
.run();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user