ebe3216c04
Pantry items can now be dragged between category sections (dnd-kit, cross-category drop only — no sortOrder column to persist within-category reorder). All 9 categories always render as sections, even empty ones, instead of only ones with items. Added an "Auto-categorize" action mirroring the shopping list's guessAisle heuristic. Fixed: the edit dialog's category dropdown displayed the raw stored slug/"__other__" instead of its translated label (SelectValue needs a value->label render function, same pattern shopping-list-view.tsx already used elsewhere). Fixed: merge-duplicates now merges by ingredient identity alone, not identity+unit — two rows merge even with mismatched, missing, or different-unit quantities, only summing when safe to do so. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
92 lines
3.9 KiB
TypeScript
92 lines
3.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { db, pantryItems, eq, asc, inArray } from "@epicure/db";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { loadIngredientAliasIndex, resolveIngredientKey } from "@/lib/ingredient-match";
|
|
|
|
function normalizeUnit(unit: string | null): string {
|
|
return (unit ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* One-shot cleanup for pantry items that turn out to be the same ingredient
|
|
* under different names ("sel", "sel fin", "sel de table") — a case the
|
|
* alias index (lib/ingredient-match.ts) only prevents going forward, not
|
|
* for rows added before it existed. Groups by resolved ingredient key alone
|
|
* (unit is NOT part of the grouping key — two rows of the same ingredient
|
|
* still merge even if one has no quantity/unit at all, or the two use
|
|
* different units); for any group with more than one row, merges into the
|
|
* oldest row and deletes the rest.
|
|
*
|
|
* Quantities are only summed when every row in the group has a parseable
|
|
* quantity AND they all share the same unit — mixing units without
|
|
* conversion, or mixing a known and an unknown amount, would silently
|
|
* invent a number, so the first known (quantity, unit) pair is kept
|
|
* instead. Notes are concatenated (nothing is dropped); expiresAt keeps the
|
|
* soonest date (the conservative choice — better to under-promise
|
|
* freshness than over).
|
|
*/
|
|
export async function POST(req: NextRequest) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
if (response) return response;
|
|
const userId = session!.user.id;
|
|
|
|
const [items, aliasIndex] = await Promise.all([
|
|
db.query.pantryItems.findMany({ where: eq(pantryItems.userId, userId), orderBy: asc(pantryItems.createdAt) }),
|
|
loadIngredientAliasIndex(),
|
|
]);
|
|
|
|
const groups = new Map<string, typeof items>();
|
|
for (const item of items) {
|
|
const key = resolveIngredientKey(item.rawName, aliasIndex);
|
|
const group = groups.get(key) ?? [];
|
|
group.push(item);
|
|
groups.set(key, group);
|
|
}
|
|
|
|
let mergedGroups = 0;
|
|
let removed = 0;
|
|
const idsToDelete: string[] = [];
|
|
|
|
for (const group of groups.values()) {
|
|
if (group.length < 2) continue;
|
|
mergedGroups++;
|
|
|
|
const [survivor, ...rest] = group;
|
|
const quantities = group.map((i) => (i.quantity ? parseFloat(i.quantity) : null));
|
|
const units = group.map((i) => normalizeUnit(i.unit));
|
|
const sameUnit = units.every((u) => u === units[0]);
|
|
const allParseable = sameUnit && quantities.every((q) => q !== null && !isNaN(q));
|
|
const firstKnownIndex = quantities.findIndex((q) => q !== null && !isNaN(q));
|
|
const mergedQuantity = allParseable
|
|
? String(quantities.reduce((sum, q) => sum! + q!, 0))
|
|
: firstKnownIndex !== -1 ? quantities[firstKnownIndex]!.toString() : survivor!.quantity;
|
|
const mergedUnit = allParseable
|
|
? survivor!.unit
|
|
: firstKnownIndex !== -1 ? group[firstKnownIndex]!.unit : survivor!.unit;
|
|
|
|
const mergedNotes = [...new Set(group.map((i) => i.notes?.trim()).filter((n): n is string => !!n))].join("; ") || null;
|
|
const mergedAisle = group.find((i) => i.aisle)?.aisle ?? null;
|
|
const mergedIngredientId = group.find((i) => i.ingredientId)?.ingredientId ?? null;
|
|
const expiryDates = group.map((i) => i.expiresAt).filter((d): d is Date => d !== null);
|
|
const mergedExpiresAt = expiryDates.length > 0 ? new Date(Math.min(...expiryDates.map((d) => d.getTime()))) : null;
|
|
|
|
await db.update(pantryItems).set({
|
|
quantity: mergedQuantity,
|
|
unit: mergedUnit,
|
|
notes: mergedNotes,
|
|
aisle: mergedAisle,
|
|
ingredientId: mergedIngredientId,
|
|
expiresAt: mergedExpiresAt,
|
|
}).where(eq(pantryItems.id, survivor!.id));
|
|
|
|
idsToDelete.push(...rest.map((i) => i.id));
|
|
removed += rest.length;
|
|
}
|
|
|
|
if (idsToDelete.length > 0) {
|
|
await db.delete(pantryItems).where(inArray(pantryItems.id, idsToDelete));
|
|
}
|
|
|
|
return NextResponse.json({ mergedGroups, removed });
|
|
}
|