Files
Wabenhain/src/lib/data.ts
T
Nick SzaunigandClaude Sonnet 5 7d5c172487
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m21s
feat: eigene Bilder pro Charge
Chargen hatten bisher kein eigenes Bild-Feld (nur Ernten und Produkte) —
neue Migration ergaenzt Batch.images (Json-Array, gleiches Muster wie
Harvest.images/Product.images). Admin kann Chargenfotos jetzt unter
/admin/chargen/[id] hochladen (AdminImageListField, identisch zum
Ernten-Formular). Die oeffentliche Chargen-Seite (/charge/[code]) zeigt
diese Fotos jetzt bevorzugt, faellt ohne eigene Chargenbilder weiterhin
auf die Erntebilder zurueck.

Hinweis fuer den naechsten Live-Deploy: die generierte Migration hat
nebenbei eine bereits laenger bestehende Schema-Abweichung mitkorrigiert
(harvests.images und products.images/tasteTags werden jetzt als NOT NULL
erzwungen, wie im Schema schon laenger deklariert) - "prisma migrate
deploy" schlaegt fehl, falls dort noch echte NULL-Werte stehen sollten.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 04:08:39 +02:00

174 lines
6.4 KiB
TypeScript

import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { matchesSearchQuery } from "@/lib/search-match";
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
// siehe schema.prisma) — hier auf das tatsaechliche Laufzeitformat
// (String-Array) zurueckgeschmalert, damit der Rest der App wie zuvor damit
// arbeiten kann. normalizeProduct() unten stellt sicher, dass das an jeder
// Rueckgabestelle in diesem Modul auch wirklich so ankommt.
export type ProductWithRelations = Omit<Product, "tasteTags" | "images"> & {
tasteTags: string[];
images: string[];
harvest: Harvest | null;
batch: Batch | null;
inventory: { quantity: number } | null;
};
function normalizeProduct<T extends { tasteTags: unknown; images: unknown }>(
product: T
): Omit<T, "tasteTags" | "images"> & { tasteTags: string[]; images: string[] } {
return { ...product, tasteTags: asStringArray(product.tasteTags), images: asStringArray(product.images) };
}
/** Dasselbe Prinzip wie normalizeProduct oben, nur fuer das images-Feld
* alleine — wiederverwendbar fuer Harvest UND fuer die (verschachtelten)
* Product-Eintraege in den Harvest-/Batch-Funktionen unten, die keine
* tasteTags-Normalisierung brauchen. */
function withImages<T extends { images: unknown }>(item: T): Omit<T, "images"> & { images: string[] } {
return { ...item, images: asStringArray(item.images) };
}
export type HarvestWithImages = Omit<Harvest, "images"> & { images: string[] };
export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
export interface GetActiveProductsOptions {
query?: string;
category?: ProductCategory;
sort?: ProductSort;
/** Nur Produkte mit Lagerbestand > 0 anzeigen. */
onlyAvailable?: boolean;
}
export async function getActiveProducts(
options: GetActiveProductsOptions = {}
): Promise<ProductWithRelations[]> {
const trimmed = options.query?.trim();
let orderBy: Prisma.ProductOrderByWithRelationInput = { createdAt: "asc" };
if (options.sort === "preis-auf") orderBy = { priceCents: "asc" };
else if (options.sort === "preis-ab") orderBy = { priceCents: "desc" };
else if (options.sort === "verfuegbarkeit") orderBy = { inventory: { quantity: "desc" } };
const products = (
await prisma.product.findMany({
where: {
isActive: true,
...(options.category ? { category: options.category } : {}),
...(options.onlyAvailable ? { inventory: { quantity: { gt: 0 } } } : {}),
},
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
orderBy,
})
).map(normalizeProduct);
if (!trimmed) return products;
// Teilstring-Suche auf einzelnen tasteTags-Elementen unterstuetzt Prisma
// fuer Postgres-Arrays nicht (nur exakte Gleichheit via "has") — bei der
// ueberschaubaren Katalogsgroesse wird deshalb hier statt in der DB-Query
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
// Tags wie "reines Bienenwachs" bei case-insensitiver Suche nie trafen.
// matchesSearchQuery gleicht zusaetzlich Umlaute/ASCII-Transliteration ab
// (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte
// Umlaute verwenden, aber nicht jede Kundin/jeder Kunde sie eintippt.
return products.filter((product) => {
const haystack = [product.name, product.description, ...product.tasteTags].join(" ");
return matchesSearchQuery(haystack, trimmed);
});
}
/** Bis zu `limit` weitere aktive Produkte derselben Kategorie fuer "Das koennte dir auch gefallen". */
export async function getRelatedProducts(
productId: string,
category: ProductCategory,
limit = 3
): Promise<ProductWithRelations[]> {
const products = await prisma.product.findMany({
where: { isActive: true, category, id: { not: productId } },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
orderBy: { createdAt: "desc" },
take: limit,
});
return products.map(normalizeProduct);
}
export async function getProductBySlug(
slug: string
): Promise<ProductWithRelations | null> {
const product = await prisma.product.findUnique({
where: { slug },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
});
return product ? normalizeProduct(product) : null;
}
export async function getProductsByIds(
ids: string[]
): Promise<ProductWithRelations[]> {
if (ids.length === 0) return [];
const products = await prisma.product.findMany({
where: { id: { in: ids } },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
});
return products.map(normalizeProduct);
}
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
export async function getHarvests(): Promise<(HarvestWithImages & { batches: Batch[] })[]> {
const harvests = await prisma.harvest.findMany({
where: { isPublished: true },
include: { batches: true },
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
});
return harvests.map(withImages);
}
/**
* Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — gibt null
* zurueck, wenn die Ernte nicht existiert ODER noch nicht veroeffentlicht
* ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
* Ernten ueber ihre Shop-URL nicht erratbar sind.
*/
export async function getHarvestByYearType(
year: number,
type: string
): Promise<
| (HarvestWithImages & {
batches: Batch[];
products: (Omit<Product, "images"> & { images: string[] })[];
})
| null
> {
const harvest = await prisma.harvest.findUnique({
where: { year_type: { year, type } },
include: { batches: true, products: true },
});
if (!harvest || !harvest.isPublished) return null;
return { ...withImages(harvest), products: harvest.products.map(withImages) };
}
export async function getBatchByCode(
code: string
): Promise<
| (Omit<Batch, "images"> & {
images: string[];
harvest: HarvestWithImages;
products: (Omit<Product, "images"> & { images: string[] })[];
})
| null
> {
const batch = await prisma.batch.findUnique({
where: { code },
include: { harvest: true, products: true },
});
if (!batch) return null;
return {
...withImages(batch),
harvest: withImages(batch.harvest),
products: batch.products.map(withImages),
};
}