feat(01-02): implement item, category, and totals service layers
- Item CRUD: getAllItems with category join, getById, create, update, delete - Category CRUD: getAll ordered by name, create, update, delete with reassignment - Totals: per-category aggregates and global totals via SQL SUM/COUNT - All 20 service tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
76
src/server/services/category.service.ts
Normal file
76
src/server/services/category.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getAllCategories(db: Db = prodDb) {
|
||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
||||
}
|
||||
|
||||
export function createCategory(
|
||||
db: Db = prodDb,
|
||||
data: { name: string; emoji?: string },
|
||||
) {
|
||||
return db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: data.name,
|
||||
...(data.emoji ? { emoji: data.emoji } : {}),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: { name?: string; emoji?: string },
|
||||
) {
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
): { success: boolean; error?: string } {
|
||||
// Guard: cannot delete Uncategorized (id=1)
|
||||
if (id === 1) {
|
||||
return { success: false, error: "Cannot delete the Uncategorized category" };
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
|
||||
// Reassign items to Uncategorized (id=1), then delete
|
||||
// Use a transaction for atomicity
|
||||
db.update(items)
|
||||
.set({ categoryId: 1 })
|
||||
.where(eq(items.categoryId, id))
|
||||
.run();
|
||||
|
||||
db.delete(categories).where(eq(categories.id, id)).run();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
112
src/server/services/item.service.ts
Normal file
112
src/server/services/item.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { items, categories } from "../../db/schema.ts";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { CreateItem } from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getAllItems(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryEmoji: categories.emoji,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getItemById(db: Db = prodDb, id: number) {
|
||||
return (
|
||||
db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
})
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function createItem(
|
||||
db: Db = prodDb,
|
||||
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string },
|
||||
) {
|
||||
return db
|
||||
.insert(items)
|
||||
.values({
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function updateItem(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists first
|
||||
const existing = db
|
||||
.select({ id: items.id })
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(items)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(items.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteItem(db: Db = prodDb, id: number) {
|
||||
// Get item first (for image cleanup info)
|
||||
const item = db
|
||||
.select()
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get();
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
db.delete(items).where(eq(items.id, id)).run();
|
||||
|
||||
return item;
|
||||
}
|
||||
32
src/server/services/totals.service.ts
Normal file
32
src/server/services/totals.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { items, categories } from "../../db/schema.ts";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getCategoryTotals(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
categoryId: items.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryEmoji: categories.emoji,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.groupBy(items.categoryId)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getGlobalTotals(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.get();
|
||||
}
|
||||
Reference in New Issue
Block a user