feat(19-02): add COALESCE merge for reference items in item service
- getAllItems and getItemById LEFT JOIN globalItems with COALESCE for name, weight, price, image - createItem accepts globalItemId and purchasePriceCents, stores brand+model as fallback name - duplicateItem preserves globalItemId and purchasePriceCents - updateItem type includes globalItemId and purchasePriceCents - 10 new tests for reference item creation and merged data retrieval
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import type { db as prodDb } from "../../db/index.ts";
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
import { categories, items } from "../../db/schema.ts";
|
import { categories, globalItems, items } from "../../db/schema.ts";
|
||||||
import type { CreateItem } from "../../shared/types.ts";
|
import type { CreateItem } from "../../shared/types.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
@@ -9,15 +9,32 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
id: items.id,
|
id: items.id,
|
||||||
name: items.name,
|
name: sql<string>`COALESCE(
|
||||||
weightGrams: items.weightGrams,
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
priceCents: items.priceCents,
|
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
||||||
|
ELSE ${items.name}
|
||||||
|
END,
|
||||||
|
${items.name}
|
||||||
|
)`.as("name"),
|
||||||
|
weightGrams: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
||||||
|
${items.weightGrams}
|
||||||
|
)`.as("weight_grams"),
|
||||||
|
priceCents: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
||||||
|
${items.priceCents}
|
||||||
|
)`.as("price_cents"),
|
||||||
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
quantity: items.quantity,
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
imageFilename: items.imageFilename,
|
imageFilename: sql<string | null>`COALESCE(
|
||||||
|
${items.imageFilename},
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
||||||
|
)`.as("image_filename"),
|
||||||
imageSourceUrl: items.imageSourceUrl,
|
imageSourceUrl: items.imageSourceUrl,
|
||||||
|
globalItemId: items.globalItemId,
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
@@ -25,6 +42,7 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
.where(eq(items.userId, userId));
|
.where(eq(items.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +50,36 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
id: items.id,
|
id: items.id,
|
||||||
name: items.name,
|
name: sql<string>`COALESCE(
|
||||||
weightGrams: items.weightGrams,
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
priceCents: items.priceCents,
|
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
||||||
|
ELSE ${items.name}
|
||||||
|
END,
|
||||||
|
${items.name}
|
||||||
|
)`.as("name"),
|
||||||
|
weightGrams: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
||||||
|
${items.weightGrams}
|
||||||
|
)`.as("weight_grams"),
|
||||||
|
priceCents: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
||||||
|
${items.priceCents}
|
||||||
|
)`.as("price_cents"),
|
||||||
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
imageFilename: items.imageFilename,
|
imageFilename: sql<string | null>`COALESCE(
|
||||||
|
${items.imageFilename},
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
||||||
|
)`.as("image_filename"),
|
||||||
imageSourceUrl: items.imageSourceUrl,
|
imageSourceUrl: items.imageSourceUrl,
|
||||||
|
globalItemId: items.globalItemId,
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
@@ -58,10 +94,22 @@ export async function createItem(
|
|||||||
imageFilename?: string;
|
imageFilename?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
// For reference items, look up global item for fallback name (items.name is NOT NULL)
|
||||||
|
let name = data.name;
|
||||||
|
if (data.globalItemId) {
|
||||||
|
const [gi] = await db
|
||||||
|
.select({ brand: globalItems.brand, model: globalItems.model })
|
||||||
|
.from(globalItems)
|
||||||
|
.where(eq(globalItems.id, data.globalItemId));
|
||||||
|
if (gi) {
|
||||||
|
name = `${gi.brand} ${gi.model}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(items)
|
.insert(items)
|
||||||
.values({
|
.values({
|
||||||
name: data.name,
|
name,
|
||||||
weightGrams: data.weightGrams ?? null,
|
weightGrams: data.weightGrams ?? null,
|
||||||
priceCents: data.priceCents ?? null,
|
priceCents: data.priceCents ?? null,
|
||||||
quantity: data.quantity ?? 1,
|
quantity: data.quantity ?? 1,
|
||||||
@@ -71,6 +119,8 @@ export async function createItem(
|
|||||||
productUrl: data.productUrl ?? null,
|
productUrl: data.productUrl ?? null,
|
||||||
imageFilename: data.imageFilename ?? null,
|
imageFilename: data.imageFilename ?? null,
|
||||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||||
|
globalItemId: data.globalItemId ?? null,
|
||||||
|
purchasePriceCents: data.purchasePriceCents ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -91,6 +141,8 @@ export async function updateItem(
|
|||||||
productUrl: string;
|
productUrl: string;
|
||||||
imageFilename: string;
|
imageFilename: string;
|
||||||
imageSourceUrl: string;
|
imageSourceUrl: string;
|
||||||
|
globalItemId: number;
|
||||||
|
purchasePriceCents: number;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
// Check if item exists and belongs to user
|
// Check if item exists and belongs to user
|
||||||
@@ -131,6 +183,8 @@ export async function duplicateItem(db: Db, userId: number, id: number) {
|
|||||||
imageFilename: source.imageFilename,
|
imageFilename: source.imageFilename,
|
||||||
imageSourceUrl: source.imageSourceUrl,
|
imageSourceUrl: source.imageSourceUrl,
|
||||||
quantity: source.quantity,
|
quantity: source.quantity,
|
||||||
|
globalItemId: source.globalItemId,
|
||||||
|
purchasePriceCents: source.purchasePriceCents,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { globalItems } from "../../src/db/schema.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
@@ -168,6 +169,216 @@ describe("Item Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reference items (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("createItem with globalItemId creates a reference item with globalItemId set", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Big Agnes",
|
||||||
|
model: "Copper Spur HV UL2",
|
||||||
|
weightGrams: 1270,
|
||||||
|
priceCents: 44995,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item?.globalItemId).toBe(gi.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createItem with globalItemId stores brand+model as fallback name", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "Hubba Hubba",
|
||||||
|
weightGrams: 1540,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item?.name).toBe("MSR Hubba Hubba");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems returns merged name from global item for reference items", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
weightGrams: 425,
|
||||||
|
priceCents: 17995,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].name).toBe("Nemo Tensor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems returns merged weightGrams from global item for reference items", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
weightGrams: 425,
|
||||||
|
priceCents: 17995,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all[0].weightGrams).toBe(425);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems returns merged priceCents from global item for reference items", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
weightGrams: 425,
|
||||||
|
priceCents: 17995,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all[0].priceCents).toBe(17995);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems returns item's own imageFilename when set, ignoring global imageUrl", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
imageUrl: "https://example.com/global.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
imageFilename: "local.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all[0].imageFilename).toBe("local.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems falls back to global imageUrl when item has no imageFilename", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Nemo",
|
||||||
|
model: "Tensor",
|
||||||
|
imageUrl: "https://example.com/global.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all[0].imageFilename).toBe("https://example.com/global.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllItems returns standalone item data unchanged", async () => {
|
||||||
|
await createItem(db, userId, {
|
||||||
|
name: "My Manual Tent",
|
||||||
|
weightGrams: 1500,
|
||||||
|
priceCents: 25000,
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await getAllItems(db, userId);
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].name).toBe("My Manual Tent");
|
||||||
|
expect(all[0].weightGrams).toBe(1500);
|
||||||
|
expect(all[0].priceCents).toBe(25000);
|
||||||
|
expect(all[0].globalItemId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getItemById returns merged data for a reference item", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "Thermarest",
|
||||||
|
model: "NeoAir XLite",
|
||||||
|
weightGrams: 340,
|
||||||
|
priceCents: 20995,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await getItemById(db, userId, created?.id);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item?.name).toBe("Thermarest NeoAir XLite");
|
||||||
|
expect(item?.weightGrams).toBe(340);
|
||||||
|
expect(item?.priceCents).toBe(20995);
|
||||||
|
expect(item?.globalItemId).toBe(gi.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicateItem on a reference item preserves globalItemId", async () => {
|
||||||
|
const gi = await insertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "PocketRocket 2",
|
||||||
|
weightGrams: 73,
|
||||||
|
priceCents: 4995,
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = await createItem(db, userId, {
|
||||||
|
name: "placeholder",
|
||||||
|
categoryId: 1,
|
||||||
|
globalItemId: gi.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copy = await duplicateItem(db, userId, original?.id);
|
||||||
|
expect(copy).toBeDefined();
|
||||||
|
expect(copy?.globalItemId).toBe(gi.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createItem with purchasePriceCents stores the value", async () => {
|
||||||
|
const item = await createItem(db, userId, {
|
||||||
|
name: "Discounted Tent",
|
||||||
|
categoryId: 1,
|
||||||
|
purchasePriceCents: 29999,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item?.purchasePriceCents).toBe(29999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("cross-user isolation", () => {
|
describe("cross-user isolation", () => {
|
||||||
it("user cannot see other user's items", async () => {
|
it("user cannot see other user's items", async () => {
|
||||||
const userId2 = await createSecondTestUser(db);
|
const userId2 = await createSecondTestUser(db);
|
||||||
|
|||||||
Reference in New Issue
Block a user