fix: fehlende Dateien aus vorherigem Commit nachreichen

CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die
Harvest-isPublished-Migration waren gestaged aber nicht im letzten
Commit enthalten — Gitea-Actions-Build brach mit "Module not found"
fuer genau diese Dateien ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 20:10:50 +02:00
1 parent 4d3100e9ba
commit b6db9254a9
10 files changed
+1205

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
fetchApiCoreHarvests,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreHarvestWithApiaryName,
} from "@/lib/apicore-client";
import { requireAdmin } from "./_shared";
/**
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen.
*/
function slugFor(sourceId: string): string {
return `apicore-${sourceId}`;
}
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
*/
function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}
/**
* Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an
* (isPublished: false) bzw. aktualisiert bereits vorhandene, noch
* unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte,
* frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein
* erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des
* Admins stillschweigend zuruecksetzen.
*
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
* Review korrigiert werden.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
sourceHarvests = await fetchApiCoreHarvests();
} catch (error: unknown) {
const message =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
}
let created = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
for (const source of sourceHarvests) {
const slug = slugFor(source.id);
const date = new Date(source.date);
if (Number.isNaN(date.getTime())) {
failed++;
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
continue;
}
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
try {
const existing = await prisma.harvest.findUnique({ where: { slug } });
if (existing?.isPublished) {
skipped++;
continue;
}
const data = {
year: date.getFullYear(),
type,
harvestDate: date,
location: source.apiaryName ?? "Altenmünster",
description: buildDescription(source),
};
if (existing) {
await prisma.harvest.update({ where: { slug }, data });
updated++;
} else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
created++;
}
} catch (error: unknown) {
failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`);
}
}
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
});
revalidatePath("/admin/ernten");
redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`);
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared";
/**
* Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine
* bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im
* Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine
* Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei
* DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer
* klaren Meldung ab, statt stillschweigend zu ueberschreiben.
*/
export async function createDhlLabelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const detailPath = `/admin/bestellungen/${orderId}`;
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
items: { select: { weightGrams: true, quantity: true } },
payment: { select: { status: true } },
shipment: { select: { trackingNumber: true } },
},
});
if (!order) {
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
}
if (order.fulfillmentMethod !== "VERSAND") {
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
}
if (order.payment?.status !== "SUCCEEDED") {
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
}
if (order.shipment?.trackingNumber) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
}
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
const company = await getCompanyInfo();
let result: DhlCreateShipmentResult;
try {
result = await createDhlShipmentLabel({
reference: order.orderNumber,
weightGrams: totalWeightGrams,
shipper: {
companyName: company.name,
firstName: "",
lastName: "",
street: company.address.street,
zip: company.address.zip,
city: company.address.city,
// Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst
// fest verdrahtet statt aus dem lokalisierten Anzeigetext
// ("Deutschland") geparst.
country: "DE",
phone: company.phone,
email: company.email,
},
receiver: {
firstName: order.firstName,
lastName: order.lastName,
street: order.street,
zip: order.zip,
city: order.city,
country: order.country,
phone: order.phone,
email: order.email,
},
});
} catch (error: unknown) {
if (error instanceof DhlNotConfiguredError) {
fail(detailPath, error.message);
}
if (error instanceof DhlApiError) {
fail(detailPath, error.message);
}
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
}
await prisma.shipment.upsert({
where: { orderId },
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
});
await logAdminAction(admin, {
action: "order.dhl_label_created",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`,
});
revalidatePath("/admin/bestellungen");
revalidatePath(detailPath);
}