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:
@@ -1,16 +1,8 @@
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import {
|
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||||
createItemSchema,
|
|
||||||
linkItemSchema,
|
|
||||||
updateItemSchema,
|
|
||||||
} from "../../shared/schemas.ts";
|
|
||||||
import { parseId } from "../lib/params.ts";
|
import { parseId } from "../lib/params.ts";
|
||||||
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
|
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
|
||||||
import {
|
|
||||||
linkItemToGlobal,
|
|
||||||
unlinkItemFromGlobal,
|
|
||||||
} from "../services/global-item.service.ts";
|
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
@@ -122,32 +114,5 @@ app.delete("/:id", async (c) => {
|
|||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/:id/link", zValidator("json", linkItemSchema), (c) => {
|
|
||||||
const db = c.get("db");
|
|
||||||
const id = parseId(c.req.param("id"));
|
|
||||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
|
||||||
|
|
||||||
const item = getItemById(db, id);
|
|
||||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const link = linkItemToGlobal(db, id, c.req.valid("json").globalItemId);
|
|
||||||
return c.json(link, 201);
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Item already linked to a global item" }, 409);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/:id/link", (c) => {
|
|
||||||
const db = c.get("db");
|
|
||||||
const id = parseId(c.req.param("id"));
|
|
||||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
|
||||||
|
|
||||||
const item = getItemById(db, id);
|
|
||||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
|
||||||
|
|
||||||
unlinkItemFromGlobal(db, id);
|
|
||||||
return c.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export { app as itemRoutes };
|
export { app as itemRoutes };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { and, asc, desc, eq, max, sql } from "drizzle-orm";
|
|||||||
import type { db as prodDb } from "../../db/index.ts";
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
categories,
|
categories,
|
||||||
|
globalItems,
|
||||||
items,
|
items,
|
||||||
threadCandidates,
|
threadCandidates,
|
||||||
threads,
|
threads,
|
||||||
@@ -79,17 +80,33 @@ export async function getThreadWithCandidates(
|
|||||||
.select({
|
.select({
|
||||||
id: threadCandidates.id,
|
id: threadCandidates.id,
|
||||||
threadId: threadCandidates.threadId,
|
threadId: threadCandidates.threadId,
|
||||||
name: threadCandidates.name,
|
name: sql<string>`COALESCE(
|
||||||
weightGrams: threadCandidates.weightGrams,
|
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
|
||||||
priceCents: threadCandidates.priceCents,
|
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,
|
categoryId: threadCandidates.categoryId,
|
||||||
notes: threadCandidates.notes,
|
notes: threadCandidates.notes,
|
||||||
productUrl: threadCandidates.productUrl,
|
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,
|
imageSourceUrl: threadCandidates.imageSourceUrl,
|
||||||
status: threadCandidates.status,
|
status: threadCandidates.status,
|
||||||
pros: threadCandidates.pros,
|
pros: threadCandidates.pros,
|
||||||
cons: threadCandidates.cons,
|
cons: threadCandidates.cons,
|
||||||
|
globalItemId: threadCandidates.globalItemId,
|
||||||
createdAt: threadCandidates.createdAt,
|
createdAt: threadCandidates.createdAt,
|
||||||
updatedAt: threadCandidates.updatedAt,
|
updatedAt: threadCandidates.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
@@ -97,6 +114,7 @@ export async function getThreadWithCandidates(
|
|||||||
})
|
})
|
||||||
.from(threadCandidates)
|
.from(threadCandidates)
|
||||||
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||||
|
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
|
||||||
.where(eq(threadCandidates.threadId, threadId))
|
.where(eq(threadCandidates.threadId, threadId))
|
||||||
.orderBy(asc(threadCandidates.sortOrder));
|
.orderBy(asc(threadCandidates.sortOrder));
|
||||||
|
|
||||||
@@ -190,6 +208,7 @@ export async function createCandidate(
|
|||||||
pros: data.pros ?? null,
|
pros: data.pros ?? null,
|
||||||
cons: data.cons ?? null,
|
cons: data.cons ?? null,
|
||||||
sortOrder: nextSortOrder,
|
sortOrder: nextSortOrder,
|
||||||
|
globalItemId: data.globalItemId ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -332,10 +351,30 @@ export async function resolveThread(
|
|||||||
? candidate.categoryId
|
? candidate.categoryId
|
||||||
: await getOrCreateUncategorized(tx as unknown as Db, userId);
|
: await getOrCreateUncategorized(tx as unknown as Db, userId);
|
||||||
|
|
||||||
// 4. Create collection item from candidate data — with userId
|
// 4. Create collection item — branched on catalog link
|
||||||
const [newItem] = await tx
|
let insertValues: Record<string, unknown>;
|
||||||
.insert(items)
|
if (candidate.globalItemId) {
|
||||||
.values({
|
// 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,
|
name: candidate.name,
|
||||||
weightGrams: candidate.weightGrams,
|
weightGrams: candidate.weightGrams,
|
||||||
priceCents: candidate.priceCents,
|
priceCents: candidate.priceCents,
|
||||||
@@ -346,7 +385,11 @@ export async function resolveThread(
|
|||||||
imageFilename: candidate.imageFilename,
|
imageFilename: candidate.imageFilename,
|
||||||
imageSourceUrl: candidate.imageSourceUrl,
|
imageSourceUrl: candidate.imageSourceUrl,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
})
|
};
|
||||||
|
}
|
||||||
|
const [newItem] = await tx
|
||||||
|
.insert(items)
|
||||||
|
.values(insertValues as any)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// 5. Archive the thread
|
// 5. Archive the thread
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { globalItems, items } from "../../src/db/schema.ts";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createCandidate,
|
createCandidate,
|
||||||
createThread,
|
createThread,
|
||||||
@@ -616,6 +618,161 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("catalog-linked candidates (globalItemId)", () => {
|
||||||
|
async function insertGlobalItem(
|
||||||
|
testDb: any,
|
||||||
|
data: {
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
weightGrams?: number;
|
||||||
|
priceCents?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [row] = await testDb
|
||||||
|
.insert(globalItems)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("createCandidate with globalItemId stores the value on the candidate row", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Big Agnes",
|
||||||
|
model: "Copper Spur HV UL2",
|
||||||
|
weightGrams: 1270,
|
||||||
|
priceCents: 44995,
|
||||||
|
});
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Tent Research",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const candidate = await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(candidate).toBeDefined();
|
||||||
|
expect(candidate.globalItemId).toBe(gi.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getThreadWithCandidates returns globalItemId field on each candidate", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "Hubba Hubba",
|
||||||
|
weightGrams: 1540,
|
||||||
|
});
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Tent Options",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getThreadWithCandidates(db, userId, thread.id);
|
||||||
|
expect(result?.candidates).toHaveLength(1);
|
||||||
|
expect(result?.candidates[0].globalItemId).toBe(gi.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getThreadWithCandidates merges global item data for candidates with globalItemId", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
weightGrams: 425,
|
||||||
|
priceCents: 17995,
|
||||||
|
});
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Pad Research",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getThreadWithCandidates(db, userId, thread.id);
|
||||||
|
expect(result?.candidates[0].name).toBe("Nemo Tensor");
|
||||||
|
expect(result?.candidates[0].weightGrams).toBe(425);
|
||||||
|
expect(result?.candidates[0].priceCents).toBe(17995);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveThread with candidate having globalItemId creates a reference item", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Thermarest",
|
||||||
|
model: "NeoAir XLite",
|
||||||
|
weightGrams: 340,
|
||||||
|
priceCents: 20995,
|
||||||
|
});
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Pad Decision",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const candidate = await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
notes: "Great pad",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.item).toBeDefined();
|
||||||
|
expect(result.item?.globalItemId).toBe(gi.id);
|
||||||
|
expect(result.item?.name).toBe("Thermarest NeoAir XLite");
|
||||||
|
// Reference item should NOT copy weight/price from candidate
|
||||||
|
expect(result.item?.weightGrams).toBeNull();
|
||||||
|
expect(result.item?.priceCents).toBeNull();
|
||||||
|
expect(result.item?.notes).toBe("Great pad");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveThread with standalone candidate creates a full data copy item", async () => {
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Stove Decision",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const candidate = await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "Jetboil Flash",
|
||||||
|
categoryId: 1,
|
||||||
|
weightGrams: 371,
|
||||||
|
priceCents: 10995,
|
||||||
|
notes: "Fast boil",
|
||||||
|
productUrl: "https://example.com/jetboil",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.item?.globalItemId).toBeNull();
|
||||||
|
expect(result.item?.name).toBe("Jetboil Flash");
|
||||||
|
expect(result.item?.weightGrams).toBe(371);
|
||||||
|
expect(result.item?.priceCents).toBe(10995);
|
||||||
|
expect(result.item?.productUrl).toBe("https://example.com/jetboil");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveThread reference item has brand+model as fallback name", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "PocketRocket 2",
|
||||||
|
});
|
||||||
|
const thread = await createThread(db, userId, {
|
||||||
|
name: "Stove Research",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const candidate = await createCandidate(db, userId, thread.id, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||||
|
expect(result.item?.name).toBe("MSR PocketRocket 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("cross-user isolation", () => {
|
describe("cross-user isolation", () => {
|
||||||
it("user cannot see other user's threads", async () => {
|
it("user cannot see other user's threads", async () => {
|
||||||
const userId2 = await createSecondTestUser(db);
|
const userId2 = await createSecondTestUser(db);
|
||||||
|
|||||||
Reference in New Issue
Block a user