feat(19-02): add catalog-linked candidates, branched resolution, remove link/unlink routes

- getThreadWithCandidates LEFT JOINs globalItems with COALESCE for name, weight, price, image
- createCandidate accepts and stores globalItemId
- resolveThread branches: reference item (globalItemId set) vs standalone (full data copy)
- Removed link/unlink endpoints from items route (replaced by direct globalItemId FK)
- 6 new tests for catalog-linked candidates and branched resolution
This commit is contained in:
2026-04-05 20:49:56 +02:00
parent d1ffd79bbb
commit 8a5ee731d0
3 changed files with 210 additions and 45 deletions

View File

@@ -2,6 +2,7 @@ import { and, asc, desc, eq, max, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import {
categories,
globalItems,
items,
threadCandidates,
threads,
@@ -79,17 +80,33 @@ export async function getThreadWithCandidates(
.select({
id: threadCandidates.id,
threadId: threadCandidates.threadId,
name: threadCandidates.name,
weightGrams: threadCandidates.weightGrams,
priceCents: threadCandidates.priceCents,
name: sql<string>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${threadCandidates.name}
END,
${threadCandidates.name}
)`.as("name"),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${threadCandidates.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${threadCandidates.priceCents}
)`.as("price_cents"),
categoryId: threadCandidates.categoryId,
notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename,
imageFilename: sql<string | null>`COALESCE(
${threadCandidates.imageFilename},
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
imageSourceUrl: threadCandidates.imageSourceUrl,
status: threadCandidates.status,
pros: threadCandidates.pros,
cons: threadCandidates.cons,
globalItemId: threadCandidates.globalItemId,
createdAt: threadCandidates.createdAt,
updatedAt: threadCandidates.updatedAt,
categoryName: categories.name,
@@ -97,6 +114,7 @@ export async function getThreadWithCandidates(
})
.from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
.where(eq(threadCandidates.threadId, threadId))
.orderBy(asc(threadCandidates.sortOrder));
@@ -190,6 +208,7 @@ export async function createCandidate(
pros: data.pros ?? null,
cons: data.cons ?? null,
sortOrder: nextSortOrder,
globalItemId: data.globalItemId ?? null,
})
.returning();
@@ -332,10 +351,30 @@ export async function resolveThread(
? candidate.categoryId
: await getOrCreateUncategorized(tx as unknown as Db, userId);
// 4. Create collection item from candidate data — with userId
const [newItem] = await tx
.insert(items)
.values({
// 4. Create collection item — branched on catalog link
let insertValues: Record<string, unknown>;
if (candidate.globalItemId) {
// Reference item — link to global, personal fields only
const [gi] = await tx
.select()
.from(globalItems)
.where(eq(globalItems.id, candidate.globalItemId));
const fallbackName = gi
? `${gi.brand} ${gi.model}`
: candidate.name;
insertValues = {
name: fallbackName,
globalItemId: candidate.globalItemId,
categoryId: safeCategoryId,
userId,
notes: candidate.notes,
imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl,
quantity: 1,
};
} else {
// Standalone item — full data copy (existing behavior)
insertValues = {
name: candidate.name,
weightGrams: candidate.weightGrams,
priceCents: candidate.priceCents,
@@ -346,7 +385,11 @@ export async function resolveThread(
imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl,
quantity: 1,
})
};
}
const [newItem] = await tx
.insert(items)
.values(insertValues as any)
.returning();
// 5. Archive the thread