Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads). - lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen. - app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- + Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF). - app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine zur Laufzeit hinzugefuegten Dateien bedienen) — mit Path-Traversal-Schutz und nosniff-Header. - content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite- Grafiken, admin/inhalte hat dafuer ein Upload-Widget. - Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer Product.images/Harvest.images (beide Felder existierten im Schema, hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt Platzhalter, sobald vorhanden. - Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt. Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm build clean. Upload-/Serving-Pipeline live gegen einen laufenden Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung, Format-Ablehnung) — ein vollstaendiger authentifizierter Upload end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live durchgespielt, nur per Code-Review verifiziert. Co-Authored-By: Claude Sonnet 5 <[email protected]>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { asStringArray } from "@/lib/json-array";
|
|
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.
|
|
const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
return products.filter((product) => {
|
|
const haystack = [product.name, product.description, ...product.tasteTags]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return words.every((word) => haystack.includes(word));
|
|
});
|
|
}
|
|
|
|
/** 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<
|
|
| (Batch & {
|
|
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 { ...batch, harvest: withImages(batch.harvest), products: batch.products.map(withImages) };
|
|
}
|