Auf der Set-Detailseite lassen sich Preise notieren: Betrag, Zustand, Datum, wo gesehen und eine Notiz. Daraus entsteht eine Reihe statt eines einzelnen Feldes, denn die interessante Frage ist nicht "was kostet es", sondern "was hat es seit dem Kauf gemacht". Als Ausgangswert dient die UVP, sonst die aelteste Beobachtung; der aktuelle Wert ist die neueste. Fallen am selben Tag zwei Beobachtungen an, gewinnt der Neupreis - das ist die Zahl, die man zum Ersetzen zahlen wuerde. Die Statistik-Kachel in der Sammlung zeigt entsprechend "Aktueller Wert", sobald irgendwo ein Preis notiert ist, sonst unveraendert "Wert nach UVP". Bewusst nicht dabei: die Anbindung des BrickLink-Preisfuehrers. Sie war im Plan als optional vermerkt und braucht OAuth1 mit einer pro Nutzer registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Die Datenstruktur ist dafuer offen: das Feld "Wo" nimmt jede Quelle auf. 13 Tests fuer die Rechenwege, inklusive Division durch null, fehlender UVP und Rundung. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
263 lines
7.2 KiB
TypeScript
263 lines
7.2 KiB
TypeScript
import 'server-only';
|
|
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
import {
|
|
collectionItem,
|
|
instruction,
|
|
manufacturer,
|
|
photo,
|
|
pricePoint,
|
|
set,
|
|
theme,
|
|
} from '@/db/schema';
|
|
import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
|
|
|
|
export const CATALOG_PAGE_SIZE = 48;
|
|
|
|
export type CatalogFilters = {
|
|
q?: string;
|
|
manufacturerId?: string;
|
|
themeId?: string;
|
|
year?: number;
|
|
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;
|
|
};
|
|
|
|
export type CatalogRow = {
|
|
id: string;
|
|
setNumber: string;
|
|
name: string;
|
|
year: number | null;
|
|
pieceCount: number | null;
|
|
imageUrl: string | null;
|
|
imagePath: string | null;
|
|
retailPriceCents: number | null;
|
|
manufacturerName: string;
|
|
themeName: string | null;
|
|
ownedCopies: number;
|
|
wishlistCount: number;
|
|
};
|
|
|
|
function buildWhere(filters: CatalogFilters): SQL | undefined {
|
|
const conditions: SQL[] = [];
|
|
|
|
const q = filters.q?.trim();
|
|
if (q) {
|
|
// Matching set number and name covers how people actually search: either
|
|
// they read the number off the box, or they remember the name.
|
|
const pattern = `%${q.toLowerCase()}%`;
|
|
const match = or(
|
|
like(sql`lower(${set.setNumber})`, pattern),
|
|
like(sql`lower(${set.name})`, pattern),
|
|
like(sql`lower(coalesce(${set.ean}, ''))`, pattern),
|
|
);
|
|
if (match) conditions.push(match);
|
|
}
|
|
|
|
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
|
|
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, filters.collectorId);
|
|
switch (filters.ownership) {
|
|
case 'sammlung':
|
|
conditions.push(sql`${owned} > 0`);
|
|
break;
|
|
case 'fehlt':
|
|
conditions.push(sql`${owned} = 0`);
|
|
break;
|
|
case 'wunschliste':
|
|
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (filters.excludeWishlistId) {
|
|
conditions.push(
|
|
sql`not exists (
|
|
select 1 from wishlist_item wi
|
|
where wi.set_id = ${set.id} and wi.wishlist_id = ${filters.excludeWishlistId}
|
|
)`,
|
|
);
|
|
}
|
|
|
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
}
|
|
|
|
export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total: number } {
|
|
const where = buildWhere(filters);
|
|
const page = Math.max(1, filters.page ?? 1);
|
|
|
|
const rows = db
|
|
.select({
|
|
id: set.id,
|
|
setNumber: set.setNumber,
|
|
name: set.name,
|
|
year: set.year,
|
|
pieceCount: set.pieceCount,
|
|
imageUrl: set.imageUrl,
|
|
imagePath: set.imagePath,
|
|
retailPriceCents: set.retailPriceCents,
|
|
manufacturerName: manufacturer.name,
|
|
themeName: theme.name,
|
|
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))
|
|
.leftJoin(theme, eq(theme.id, set.themeId))
|
|
.where(where)
|
|
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
|
.limit(CATALOG_PAGE_SIZE)
|
|
.offset((page - 1) * CATALOG_PAGE_SIZE)
|
|
.all();
|
|
|
|
const [totals] = db
|
|
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
|
.from(set)
|
|
.where(where)
|
|
.all();
|
|
|
|
return { rows, total: totals?.total ?? 0 };
|
|
}
|
|
|
|
/** All set ids matching the current filters - used by "alle auswählen". */
|
|
export function listSetIds(filters: CatalogFilters): string[] {
|
|
return db
|
|
.select({ id: set.id })
|
|
.from(set)
|
|
.where(buildWhere(filters))
|
|
.all()
|
|
.map((row) => row.id);
|
|
}
|
|
|
|
export function getSetById(id: string, collectorId?: string) {
|
|
const row = db
|
|
.select({
|
|
id: set.id,
|
|
manufacturerId: set.manufacturerId,
|
|
manufacturerName: manufacturer.name,
|
|
manufacturerWebsite: manufacturer.website,
|
|
themeId: set.themeId,
|
|
themeName: theme.name,
|
|
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,
|
|
createdAt: set.createdAt,
|
|
updatedAt: set.updatedAt,
|
|
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
|
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
|
|
})
|
|
.from(set)
|
|
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
|
.leftJoin(theme, eq(theme.id, set.themeId))
|
|
.where(eq(set.id, id))
|
|
.get();
|
|
|
|
return row ?? null;
|
|
}
|
|
|
|
/**
|
|
* Lookup by barcode. EANs are stored as typed, so the comparison strips
|
|
* everything but digits on both sides - a scanner never produces the spaces
|
|
* and dashes people write by hand.
|
|
*/
|
|
export function findSetByEan(ean: string) {
|
|
const digits = ean.replace(/\D/g, '');
|
|
if (digits.length === 0) return null;
|
|
|
|
return (
|
|
db
|
|
.select({ id: set.id, setNumber: set.setNumber, name: set.name })
|
|
.from(set)
|
|
.where(sql`replace(replace(coalesce(${set.ean}, ''), '-', ''), ' ', '') = ${digits}`)
|
|
.get() ?? null
|
|
);
|
|
}
|
|
|
|
export function getSetByNumber(manufacturerId: string, setNumber: string) {
|
|
return (
|
|
db
|
|
.select({ id: set.id })
|
|
.from(set)
|
|
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
|
|
.get() ?? null
|
|
);
|
|
}
|
|
|
|
export function listInstructionsForSet(setId: string) {
|
|
return db
|
|
.select()
|
|
.from(instruction)
|
|
.where(eq(instruction.setId, setId))
|
|
.orderBy(asc(instruction.sortOrder), asc(instruction.createdAt))
|
|
.all();
|
|
}
|
|
|
|
export function listPhotosForSet(setId: string) {
|
|
return db
|
|
.select()
|
|
.from(photo)
|
|
.where(eq(photo.setId, setId))
|
|
.orderBy(asc(photo.sortOrder), asc(photo.createdAt))
|
|
.all();
|
|
}
|
|
|
|
export function listCollectionItemsForSet(setId: string) {
|
|
return db
|
|
.select()
|
|
.from(collectionItem)
|
|
.where(eq(collectionItem.setId, setId))
|
|
.orderBy(desc(collectionItem.createdAt))
|
|
.all();
|
|
}
|
|
|
|
/** Distinct release years, newest first - fills the year filter. */
|
|
export function listSetYears(): number[] {
|
|
return db
|
|
.selectDistinct({ year: set.year })
|
|
.from(set)
|
|
.where(not(sql`${set.year} is null`))
|
|
.orderBy(desc(set.year))
|
|
.all()
|
|
.flatMap((row) => (row.year === null ? [] : [row.year]));
|
|
}
|
|
|
|
export type PricePointRow = {
|
|
id: string;
|
|
priceCents: number;
|
|
condition: 'new' | 'used';
|
|
source: string | null;
|
|
observedAt: Date;
|
|
note: string | null;
|
|
};
|
|
|
|
export function listPricePoints(setId: string): PricePointRow[] {
|
|
return db
|
|
.select({
|
|
id: pricePoint.id,
|
|
priceCents: pricePoint.priceCents,
|
|
condition: pricePoint.condition,
|
|
source: pricePoint.source,
|
|
observedAt: pricePoint.observedAt,
|
|
note: pricePoint.note,
|
|
})
|
|
.from(pricePoint)
|
|
.where(eq(pricePoint.setId, setId))
|
|
.orderBy(desc(pricePoint.observedAt))
|
|
.all();
|
|
}
|