Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven, nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
173 lines
7.0 KiB
TypeScript
173 lines
7.0 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { asStringArray } from "@/lib/json-array";
|
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
|
import { pickHoneyProduct, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
|
import { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending";
|
|
import type { Locale } from "@/i18n/config";
|
|
import type {
|
|
MedalTier,
|
|
SponsorshipCycle,
|
|
SponsorshipOfferKind,
|
|
} from "@/generated/prisma/client";
|
|
|
|
/**
|
|
* Angebote der Bienenpatenschaft, so aufbereitet, wie Verkaufsseite,
|
|
* Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als
|
|
* Array und mit der Aussage, ob das Angebot gerade bestellbar ist.
|
|
*
|
|
* "Bestellbar" haengt am Honig: ein Angebot mit Glaesern braucht einen Honig,
|
|
* den die automatische Wahl nehmen darf (nie cremig, neueste Ernte, genug
|
|
* FREIER Bestand, also abzueglich der fuer Abos reservierten Glaeser —
|
|
* lib/sponsorship/honey-selection.ts). Gibt es keinen, ist das Angebot nicht
|
|
* bestellbar — sonst bekaeme die Kundschaft eine Urkunde ohne den bezahlten
|
|
* Honig. Bei Abos zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions).
|
|
*
|
|
* Einen fest verknuepften Honig je Angebot gibt es nicht mehr: die Spalte
|
|
* SponsorshipOffer.honeyProductId bleibt (nullable) im Schema stehen, um
|
|
* keine Migration auf der laufenden Produktion zu brauchen, wird aber weder
|
|
* gelesen noch geschrieben.
|
|
*/
|
|
export interface SponsorshipOfferView {
|
|
id: string;
|
|
slug: string;
|
|
kind: SponsorshipOfferKind;
|
|
name: string;
|
|
description: string;
|
|
features: string[];
|
|
/** EINMALIG: Paketpreis brutto. ABO: Monatspreis brutto. */
|
|
priceCents: number;
|
|
isPhysical: boolean;
|
|
honeyJars: number;
|
|
honeyShareCentsPerJar: number;
|
|
honeyShareCentsPerMonth: number;
|
|
cycle: SponsorshipCycle | null;
|
|
medalTier: MedalTier;
|
|
/** Traegerprodukt (nur EINMALIG). */
|
|
productId: string | null;
|
|
/**
|
|
* Der Honig, den die automatische Wahl GERADE fuer dieses Angebot nimmt
|
|
* (nur zur Anzeige und Pruefung; der Checkout waehlt bei der Bestellung neu,
|
|
* weil mehrere Pakete im Warenkorb gemeinsam reichen muessen).
|
|
*/
|
|
honeyProduct: { id: string; name: string; weightGrams: number } | null;
|
|
/** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */
|
|
isAvailable: boolean;
|
|
}
|
|
|
|
async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) {
|
|
return prisma.sponsorshipOffer.findMany({
|
|
where,
|
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
|
});
|
|
}
|
|
|
|
type OfferRow = Awaited<ReturnType<typeof loadOfferRows>>[number];
|
|
|
|
function localize(row: OfferRow, locale: Locale, honey: HoneyCandidate | null, isAvailable: boolean): SponsorshipOfferView {
|
|
const en = locale === "en";
|
|
const featuresEn = asStringArray(row.featuresEn);
|
|
return {
|
|
id: row.id,
|
|
slug: row.slug,
|
|
kind: row.kind,
|
|
name: (en && row.nameEn) || row.name,
|
|
description: (en && row.descriptionEn) || row.description,
|
|
features: en && featuresEn.length > 0 ? featuresEn : asStringArray(row.features),
|
|
priceCents: row.priceCents,
|
|
isPhysical: row.isPhysical,
|
|
honeyJars: row.honeyJars,
|
|
honeyShareCentsPerJar: row.honeyShareCentsPerJar,
|
|
honeyShareCentsPerMonth: row.honeyShareCentsPerMonth,
|
|
cycle: row.cycle,
|
|
medalTier: row.medalTier,
|
|
productId: row.productId,
|
|
honeyProduct: honey ? { id: honey.id, name: honey.name, weightGrams: honey.weightGrams } : null,
|
|
isAvailable,
|
|
};
|
|
}
|
|
|
|
/** Kandidaten nur laden, wenn ein Angebot ueberhaupt Honig braucht (rein digitale Angebote nicht). */
|
|
async function honeyCandidatesFor(rows: OfferRow[]): Promise<HoneyCandidate[]> {
|
|
return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : [];
|
|
}
|
|
|
|
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
|
|
const aboIds = rows.filter((row) => row.kind === "ABO" && row.maxActiveSubscriptions !== null).map((row) => row.id);
|
|
if (aboIds.length === 0) return new Map();
|
|
const groups = await prisma.sponsorshipSubscription.groupBy({
|
|
by: ["offerId"],
|
|
// Laufende Abos plus offene Abschluesse der letzten Stunde (pending.ts):
|
|
// sonst koennten gleichzeitige Checkouts das Kontingent ueberbuchen.
|
|
where: { offerId: { in: aboIds }, ...quotaSubscriptionWhere(new Date()) },
|
|
_count: { _all: true },
|
|
});
|
|
return new Map(groups.map((group) => [group.offerId, group._count._all]));
|
|
}
|
|
|
|
/** Reine Entscheidung "bestellbar?", getrennt von den Abfragen, damit sie testbar bleibt. */
|
|
export function isOfferAvailable(input: {
|
|
isActive: boolean;
|
|
kind: SponsorshipOfferKind;
|
|
hasCarrierProduct: boolean;
|
|
honeyJars: number;
|
|
/** Die automatische Honigwahl hat fuer honeyJars Glaeser einen Honig gefunden. */
|
|
hasEligibleHoney: boolean;
|
|
maxActiveSubscriptions: number | null;
|
|
activeSubscriptions: number;
|
|
}): boolean {
|
|
if (!input.isActive) return false;
|
|
if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false;
|
|
if (input.honeyJars > 0 && !input.hasEligibleHoney) return false;
|
|
if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) {
|
|
if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function toViews(rows: OfferRow[], locale: Locale): Promise<SponsorshipOfferView[]> {
|
|
const [candidates, subscriptionCounts] = await Promise.all([honeyCandidatesFor(rows), activeSubscriptionCounts(rows)]);
|
|
return rows.map((row) => {
|
|
const honey = row.honeyJars > 0 ? pickHoneyProduct(candidates, row.honeyJars) : null;
|
|
return localize(
|
|
row,
|
|
locale,
|
|
honey,
|
|
isOfferAvailable({
|
|
isActive: row.isActive,
|
|
kind: row.kind,
|
|
hasCarrierProduct: row.productId !== null,
|
|
honeyJars: row.honeyJars,
|
|
hasEligibleHoney: honey !== null,
|
|
maxActiveSubscriptions: row.maxActiveSubscriptions,
|
|
activeSubscriptions: subscriptionCounts.get(row.id) ?? 0,
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise<SponsorshipOfferView[]> {
|
|
return toViews(await loadOfferRows({ isActive: true, ...(kind ? { kind } : {}) }), locale);
|
|
}
|
|
|
|
export async function getOfferBySlug(slug: string, locale: Locale): Promise<SponsorshipOfferView | null> {
|
|
const [view] = await toViews(await loadOfferRows({ slug }), locale);
|
|
return view ?? null;
|
|
}
|
|
|
|
/** Angebote zu Traegerprodukten (der Checkout kennt nur productIds). */
|
|
export async function getOffersByProductIds(
|
|
productIds: string[],
|
|
locale: Locale
|
|
): Promise<Map<string, SponsorshipOfferView>> {
|
|
if (productIds.length === 0) return new Map();
|
|
const views = await toViews(await loadOfferRows({ productId: { in: productIds } }), locale);
|
|
return new Map(
|
|
views.flatMap((view) => (view.productId ? [[view.productId, view] as const] : []))
|
|
);
|
|
}
|
|
|
|
/** Gibt es ueberhaupt ein aktives Angebot? Steuert Navigation und Verkaufsseite. */
|
|
export async function hasActiveOffers(): Promise<boolean> {
|
|
return (await prisma.sponsorshipOffer.count({ where: { isActive: true } })) > 0;
|
|
}
|