From 8d85d2839ec30fdaad7e838ef6297d5aa5e4ae21 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 10:41:59 +0200 Subject: [PATCH] feat(16-02): add userId scoping to item, category, totals, and CSV services - All functions accept userId as second parameter, no more prodDb defaults - All queries filter by eq(table.userId, userId) for data isolation - Get-by-id, update, delete use and() for composite id+userId conditions - deleteCategory uses dynamic getOrCreateUncategorized(db, userId) not hardcoded ID - CSV import scopes category lookup/creation and item creation to userId - CSV export filters items by userId - Category service converted from sync SQLite to async Postgres patterns --- src/server/services/category.service.ts | 97 +++++++++++++++---------- src/server/services/csv.service.ts | 27 ++++--- src/server/services/item.service.ts | 43 +++++++---- src/server/services/totals.service.ts | 10 ++- 4 files changed, 111 insertions(+), 66 deletions(-) diff --git a/src/server/services/category.service.ts b/src/server/services/category.service.ts index 39d0430..f7bccf2 100644 --- a/src/server/services/category.service.ts +++ b/src/server/services/category.service.ts @@ -20,76 +20,97 @@ export async function getOrCreateUncategorized( return created.id; } -export function getAllCategories(db: Db = prodDb) { - return db.select().from(categories).orderBy(asc(categories.name)).all(); +export async function getAllCategories(db: Db, userId: number) { + return db + .select() + .from(categories) + .where(eq(categories.userId, userId)) + .orderBy(asc(categories.name)); } -export function createCategory( - db: Db = prodDb, +export async function getCategoryById(db: Db, userId: number, id: number) { + const [row] = await db + .select() + .from(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))); + + return row ?? null; +} + +export async function createCategory( + db: Db, + userId: number, data: { name: string; icon?: string }, ) { - return db + const [row] = await db .insert(categories) .values({ name: data.name, + userId, ...(data.icon ? { icon: data.icon } : {}), }) - .returning() - .get(); + .returning(); + + return row; } -export function updateCategory( - db: Db = prodDb, +export async function updateCategory( + db: Db, + userId: number, id: number, data: { name?: string; icon?: string }, ) { - const existing = db + const [existing] = await db .select({ id: categories.id }) .from(categories) - .where(eq(categories.id, id)) - .get(); + .where(and(eq(categories.id, id), eq(categories.userId, userId))); if (!existing) return null; - return db + const [row] = await db .update(categories) .set(data) - .where(eq(categories.id, id)) - .returning() - .get(); + .where(and(eq(categories.id, id), eq(categories.userId, userId))) + .returning(); + + return row; } -export function deleteCategory( - db: Db = prodDb, +export async function deleteCategory( + db: Db, + userId: number, id: number, -): { success: boolean; error?: string } { - // Guard: cannot delete Uncategorized (id=1) - if (id === 1) { +): Promise<{ success: boolean; error?: string }> { + // Check if this is the Uncategorized category for this user + const [existing] = await db + .select({ id: categories.id, name: categories.name }) + .from(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))); + + if (!existing) { + return { success: false, error: "Category not found" }; + } + + if (existing.name === "Uncategorized") { 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(); + // Get or create Uncategorized for this user (dynamic, not hardcoded ID) + const uncategorizedId = await getOrCreateUncategorized(db, userId); - if (!existing) { - return { success: false, error: "Category not found" }; - } + // Reassign this user's items to Uncategorized, then delete atomically + await db.transaction(async (tx) => { + await tx + .update(items) + .set({ categoryId: uncategorizedId }) + .where(and(eq(items.categoryId, id), eq(items.userId, userId))); - // Reassign items to Uncategorized (id=1), then delete atomically - db.transaction(() => { - db.update(items) - .set({ categoryId: 1 }) - .where(eq(items.categoryId, id)) - .run(); - - db.delete(categories).where(eq(categories.id, id)).run(); + await tx + .delete(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))); }); return { success: true }; diff --git a/src/server/services/csv.service.ts b/src/server/services/csv.service.ts index 5469c87..bf0221a 100644 --- a/src/server/services/csv.service.ts +++ b/src/server/services/csv.service.ts @@ -1,6 +1,7 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items } from "../../db/schema.ts"; +import { getOrCreateUncategorized } from "./category.service.ts"; type Db = typeof prodDb; @@ -84,7 +85,7 @@ function parseCsv(content: string): { headers: string[]; rows: string[][] } { // ─── Export ─────────────────────────────────────────────────────────────────── -export async function exportItemsCsv(db: Db = prodDb): Promise { +export async function exportItemsCsv(db: Db, userId: number): Promise { const rows = await db .select({ name: items.name, @@ -96,7 +97,8 @@ export async function exportItemsCsv(db: Db = prodDb): Promise { productUrl: items.productUrl, }) .from(items) - .innerJoin(categories, eq(items.categoryId, categories.id)); + .innerJoin(categories, eq(items.categoryId, categories.id)) + .where(eq(items.userId, userId)); const header = "name,quantity,weightGrams,priceCents,category,notes,productUrl"; @@ -124,7 +126,8 @@ export interface ImportResult { } export async function importItemsCsv( - db: Db = prodDb, + db: Db, + userId: number, csvContent: string, ): Promise { const { headers, rows } = parseCsv(csvContent); @@ -149,11 +152,12 @@ export async function importItemsCsv( const notesIdx = headerIndex("notes"); const urlIdx = headerIndex("productUrl"); - // Build a local category cache (name → id) seeded from the DB + // Build a local category cache (name → id) seeded from user's categories const categoryCache = new Map(); const existingCategories = await db .select({ id: categories.id, name: categories.name }) - .from(categories); + .from(categories) + .where(eq(categories.userId, userId)); for (const cat of existingCategories) { categoryCache.set(cat.name.toLowerCase(), cat.id); } @@ -169,7 +173,7 @@ export async function importItemsCsv( continue; } - // Category resolution + // Category resolution — scoped to this user let categoryId: number; const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : ""; const categoryName = rawCategory || "Uncategorized"; @@ -177,11 +181,15 @@ export async function importItemsCsv( if (categoryCache.has(cacheKey)) { categoryId = categoryCache.get(cacheKey)!; + } else if (cacheKey === "uncategorized") { + // Use the shared helper for Uncategorized + categoryId = await getOrCreateUncategorized(db, userId); + categoryCache.set(cacheKey, categoryId); } else { - // Create a new category + // Create a new category for this user const [inserted] = await db .insert(categories) - .values({ name: categoryName, icon: "package" }) + .values({ name: categoryName, icon: "package", userId }) .returning(); categoryId = inserted.id; categoryCache.set(cacheKey, categoryId); @@ -225,6 +233,7 @@ export async function importItemsCsv( weightGrams, priceCents, categoryId, + userId, notes, productUrl, imageFilename: null, diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts index cb6cd80..f3c1aa7 100644 --- a/src/server/services/item.service.ts +++ b/src/server/services/item.service.ts @@ -1,11 +1,11 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items } from "../../db/schema.ts"; import type { CreateItem } from "../../shared/types.ts"; type Db = typeof prodDb; -export async function getAllItems(db: Db = prodDb) { +export async function getAllItems(db: Db, userId: number) { return db .select({ id: items.id, @@ -24,10 +24,11 @@ export async function getAllItems(db: Db = prodDb) { categoryIcon: categories.icon, }) .from(items) - .innerJoin(categories, eq(items.categoryId, categories.id)); + .innerJoin(categories, eq(items.categoryId, categories.id)) + .where(eq(items.userId, userId)); } -export async function getItemById(db: Db = prodDb, id: number) { +export async function getItemById(db: Db, userId: number, id: number) { const [row] = await db .select({ id: items.id, @@ -43,13 +44,14 @@ export async function getItemById(db: Db = prodDb, id: number) { updatedAt: items.updatedAt, }) .from(items) - .where(eq(items.id, id)); + .where(and(eq(items.id, id), eq(items.userId, userId))); return row ?? null; } export async function createItem( - db: Db = prodDb, + db: Db, + userId: number, data: Partial & { name: string; categoryId: number; @@ -64,6 +66,7 @@ export async function createItem( priceCents: data.priceCents ?? null, quantity: data.quantity ?? 1, categoryId: data.categoryId, + userId, notes: data.notes ?? null, productUrl: data.productUrl ?? null, imageFilename: data.imageFilename ?? null, @@ -75,7 +78,8 @@ export async function createItem( } export async function updateItem( - db: Db = prodDb, + db: Db, + userId: number, id: number, data: Partial<{ name: string; @@ -89,25 +93,28 @@ export async function updateItem( imageSourceUrl: string; }>, ) { - // Check if item exists first + // Check if item exists and belongs to user const [existing] = await db .select({ id: items.id }) .from(items) - .where(eq(items.id, id)); + .where(and(eq(items.id, id), eq(items.userId, userId))); if (!existing) return null; const [row] = await db .update(items) .set({ ...data, updatedAt: new Date() }) - .where(eq(items.id, id)) + .where(and(eq(items.id, id), eq(items.userId, userId))) .returning(); return row; } -export async function duplicateItem(db: Db = prodDb, id: number) { - const [source] = await db.select().from(items).where(eq(items.id, id)); +export async function duplicateItem(db: Db, userId: number, id: number) { + const [source] = await db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))); if (!source) return null; @@ -118,6 +125,7 @@ export async function duplicateItem(db: Db = prodDb, id: number) { weightGrams: source.weightGrams, priceCents: source.priceCents, categoryId: source.categoryId, + userId, notes: source.notes, productUrl: source.productUrl, imageFilename: source.imageFilename, @@ -129,13 +137,18 @@ export async function duplicateItem(db: Db = prodDb, id: number) { return row; } -export async function deleteItem(db: Db = prodDb, id: number) { +export async function deleteItem(db: Db, userId: number, id: number) { // Get item first (for image cleanup info) - const [item] = await db.select().from(items).where(eq(items.id, id)); + const [item] = await db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))); if (!item) return null; - await db.delete(items).where(eq(items.id, id)); + await db + .delete(items) + .where(and(eq(items.id, id), eq(items.userId, userId))); return item; } diff --git a/src/server/services/totals.service.ts b/src/server/services/totals.service.ts index 448eb81..d908d0c 100644 --- a/src/server/services/totals.service.ts +++ b/src/server/services/totals.service.ts @@ -1,10 +1,10 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items } from "../../db/schema.ts"; type Db = typeof prodDb; -export async function getCategoryTotals(db: Db = prodDb) { +export async function getCategoryTotals(db: Db, userId: number) { return db .select({ categoryId: items.categoryId, @@ -16,17 +16,19 @@ export async function getCategoryTotals(db: Db = prodDb) { }) .from(items) .innerJoin(categories, eq(items.categoryId, categories.id)) + .where(eq(items.userId, userId)) .groupBy(items.categoryId, categories.name, categories.icon); } -export async function getGlobalTotals(db: Db = prodDb) { +export async function getGlobalTotals(db: Db, userId: number) { const [row] = await db .select({ totalWeight: sql`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, totalCost: sql`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, itemCount: sql`COUNT(*)`, }) - .from(items); + .from(items) + .where(eq(items.userId, userId)); return row; }