fix(harvests): allow several harvests of the same type per year
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.
- Drop the (year, type) unique index. The slug is now the only unique,
public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
"apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
(decomposed umlauts) and writes out umlauts. The migration repairs
manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
writers cannot produce duplicates; known slugs are skipped up front to
keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f1c0da4673
commit
641afa1ae4
15 files changed
+407
-83
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { slugify } from "./slugify";
|
||||
|
||||
/**
|
||||
* Oeffentliche Adresse einer Ernte (/ernten/<slug>).
|
||||
*
|
||||
* Eine Imkerei erntet dieselbe Sorte mehrmals im Jahr (z.B. zwei
|
||||
* Schleuderungen Bluetenhonig im Juni und Juli). Sorte und Jahr allein
|
||||
* unterscheiden Ernten deshalb nicht — eindeutig ist nur der Slug.
|
||||
*
|
||||
* Vorschlaege in absteigender Lesbarkeit:
|
||||
* 1. "bluetenhonig-2026" — die erste Ernte einer Sorte im Jahr
|
||||
* 2. "bluetenhonig-2026-07-14" — jede weitere, am Erntedatum erkennbar
|
||||
* 3. "bluetenhonig-2026-07-14-2", "-3", … — gleicher Tag (selten, aber moeglich)
|
||||
*
|
||||
* Das Datum wird in UTC gelesen: ApiCore und das Admin-Formular liefern
|
||||
* reine Kalendertage als Mitternacht UTC.
|
||||
*/
|
||||
export const MAX_SLUG_SUFFIX = 20;
|
||||
|
||||
export function harvestSlugCandidates(type: string, harvestDate: Date): string[] {
|
||||
const base = slugify(type) || "ernte";
|
||||
const year = harvestDate.getUTCFullYear();
|
||||
const day = harvestDate.toISOString().slice(0, 10);
|
||||
const withDay = `${base}-${day}`;
|
||||
const numbered = Array.from({ length: MAX_SLUG_SUFFIX - 1 }, (_, i) => `${withDay}-${i + 2}`);
|
||||
return [`${base}-${year}`, withDay, ...numbered];
|
||||
}
|
||||
|
||||
/** Prisma-Fehler "Unique constraint failed" — ohne Bindung an EINEN der beiden generierten Clients. */
|
||||
export function isUniqueConstraintError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt `write` mit dem ersten noch freien Slug aus.
|
||||
*
|
||||
* Bewusst KEIN "erst pruefen, dann schreiben": zwischen beiden Schritten
|
||||
* koennte ein zweiter Admin-Tab oder ein paralleler Sync denselben Slug
|
||||
* belegen. Stattdessen entscheidet der Unique-Index der Datenbank — schlaegt
|
||||
* das Schreiben daran fehl, wird der naechste Vorschlag versucht.
|
||||
*
|
||||
* Voraussetzung: der einzige Unique-Index, den `write` verletzen kann, ist
|
||||
* der auf `slug`. Andere Fehler werden unveraendert weitergeworfen.
|
||||
*
|
||||
* `findTaken` filtert die schon bekannten Belegungen vorab heraus. Das ist
|
||||
* nur Komfort: Prisma protokolliert jeden abgefangenen Konflikt trotzdem als
|
||||
* "prisma:error", und ein Log voller scheinbarer Fehler fuehrt in die Irre.
|
||||
* Die Sicherheit kommt weiterhin allein vom Unique-Index.
|
||||
*/
|
||||
export async function writeWithFreeSlug<T>(
|
||||
candidates: string[],
|
||||
write: (slug: string) => Promise<T>,
|
||||
findTaken: (candidates: string[]) => Promise<string[]> = async () => []
|
||||
): Promise<T> {
|
||||
const taken = new Set(await findTaken(candidates));
|
||||
const free = candidates.filter((slug) => !taken.has(slug));
|
||||
for (const slug of free) {
|
||||
try {
|
||||
return await write(slug);
|
||||
} catch (error: unknown) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Alle ${candidates.length} Adressvorschläge sind belegt (zuletzt "${candidates.at(-1)}").`);
|
||||
}
|
||||
Reference in new issue
Block a user