feat(19-03): add COALESCE merge for reference items in secondary services

- Setup service: LEFT JOIN globalItems in getAllSetups totals and getSetupWithItems
- Totals service: LEFT JOIN globalItems in getCategoryTotals and getGlobalTotals
- Profile service: LEFT JOIN globalItems in getPublicProfile totals and getPublicSetupWithItems
- CSV service: LEFT JOIN globalItems in exportItemsCsv for merged name/weight/price
This commit is contained in:
2026-04-06 00:26:13 +02:00
parent ecc6ac689a
commit 0a233c754d
4 changed files with 124 additions and 23 deletions

View File

@@ -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 { getOrCreateUncategorized } from "./category.service.ts"; import { getOrCreateUncategorized } from "./category.service.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -88,16 +88,29 @@ function parseCsv(content: string): { headers: string[]; rows: string[][] } {
export async function exportItemsCsv(db: Db, userId: number): Promise<string> { export async function exportItemsCsv(db: Db, userId: number): Promise<string> {
const rows = await db const rows = await db
.select({ .select({
name: items.name, name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
quantity: items.quantity, quantity: items.quantity,
weightGrams: items.weightGrams, weightGrams: sql<number | null>`COALESCE(
priceCents: items.priceCents, 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"),
categoryName: categories.name, categoryName: categories.name,
notes: items.notes, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
}) })
.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));
const header = const header =

View File

@@ -2,6 +2,7 @@ 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 { import {
categories, categories,
globalItems,
items, items,
setupItems, setupItems,
setups, setups,
@@ -55,13 +56,25 @@ export async function getPublicProfile(db: Db, userId: number) {
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"), ), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE(( totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items SELECT SUM(
COALESCE(
CASE WHEN items.global_item_id IS NOT NULL THEN global_items.weight_grams ELSE NULL END,
items.weight_grams
) * items.quantity
) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
LEFT JOIN global_items ON global_items.id = items.global_item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"), ), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE(( totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents * items.quantity) FROM setup_items SELECT SUM(
COALESCE(
CASE WHEN items.global_item_id IS NOT NULL THEN global_items.price_cents ELSE NULL END,
items.price_cents
) * items.quantity
) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
LEFT JOIN global_items ON global_items.id = items.global_item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"), ), 0)`.as("total_cost"),
}) })
@@ -82,14 +95,30 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
const itemList = await db const itemList = 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"),
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"),
globalItemId: items.globalItemId,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
@@ -99,6 +128,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
.from(setupItems) .from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(eq(setupItems.setupId, setupId)); .where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList }; return { ...setup, items: itemList };

View File

@@ -1,6 +1,12 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, 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, setupItems, setups } from "../../db/schema.ts"; import {
categories,
globalItems,
items,
setupItems,
setups,
} from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts"; import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -27,13 +33,25 @@ export async function getAllSetups(db: Db, userId: number) {
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"), ), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE(( totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items SELECT SUM(
COALESCE(
CASE WHEN items.global_item_id IS NOT NULL THEN global_items.weight_grams ELSE NULL END,
items.weight_grams
) * items.quantity
) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
LEFT JOIN global_items ON global_items.id = items.global_item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"), ), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE(( totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents * items.quantity) FROM setup_items SELECT SUM(
COALESCE(
CASE WHEN items.global_item_id IS NOT NULL THEN global_items.price_cents ELSE NULL END,
items.price_cents
) * items.quantity
) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
LEFT JOIN global_items ON global_items.id = items.global_item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"), ), 0)`.as("total_cost"),
}) })
@@ -55,14 +73,31 @@ export async function getSetupWithItems(
const itemList = await db const itemList = 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"),
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"),
globalItemId: items.globalItemId,
purchasePriceCents: items.purchasePriceCents,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
@@ -72,6 +107,7 @@ export async function getSetupWithItems(
.from(setupItems) .from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(eq(setupItems.setupId, setupId)); .where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList }; return { ...setup, items: itemList };

View File

@@ -1,6 +1,6 @@
import { and, eq, sql } 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";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -10,12 +10,23 @@ export async function getCategoryTotals(db: Db, userId: number) {
categoryId: items.categoryId, categoryId: items.categoryId,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, totalWeight: sql<number>`COALESCE(SUM(
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
) * ${items.quantity}
), 0)`,
totalCost: sql<number>`COALESCE(SUM(
COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${items.priceCents}
) * ${items.quantity}
), 0)`,
itemCount: sql<number>`COUNT(*)`, itemCount: sql<number>`COUNT(*)`,
}) })
.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))
.groupBy(items.categoryId, categories.name, categories.icon); .groupBy(items.categoryId, categories.name, categories.icon);
} }
@@ -23,11 +34,22 @@ export async function getCategoryTotals(db: Db, userId: number) {
export async function getGlobalTotals(db: Db, userId: number) { export async function getGlobalTotals(db: Db, userId: number) {
const [row] = await db const [row] = await db
.select({ .select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, totalWeight: sql<number>`COALESCE(SUM(
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
) * ${items.quantity}
), 0)`,
totalCost: sql<number>`COALESCE(SUM(
COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${items.priceCents}
) * ${items.quantity}
), 0)`,
itemCount: sql<number>`COUNT(*)`, itemCount: sql<number>`COUNT(*)`,
}) })
.from(items) .from(items)
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(eq(items.userId, userId)); .where(eq(items.userId, userId));
return row; return row;