Files
bricktrack/src/lib/queries/catalog.ts
T
unknownandClaude Opus 5 91fb66b13b feat(preise): beobachtete preise und wertentwicklung je set
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>
2026-09-07 09:17:03 +02:00

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();
}