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
This commit is contained in:
@@ -20,76 +20,97 @@ export async function getOrCreateUncategorized(
|
|||||||
return created.id;
|
return created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllCategories(db: Db = prodDb) {
|
export async function getAllCategories(db: Db, userId: number) {
|
||||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
return db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.userId, userId))
|
||||||
|
.orderBy(asc(categories.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCategory(
|
export async function getCategoryById(db: Db, userId: number, id: number) {
|
||||||
db: Db = prodDb,
|
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 },
|
data: { name: string; icon?: string },
|
||||||
) {
|
) {
|
||||||
return db
|
const [row] = await db
|
||||||
.insert(categories)
|
.insert(categories)
|
||||||
.values({
|
.values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
userId,
|
||||||
...(data.icon ? { icon: data.icon } : {}),
|
...(data.icon ? { icon: data.icon } : {}),
|
||||||
})
|
})
|
||||||
.returning()
|
.returning();
|
||||||
.get();
|
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCategory(
|
export async function updateCategory(
|
||||||
db: Db = prodDb,
|
db: Db,
|
||||||
|
userId: number,
|
||||||
id: number,
|
id: number,
|
||||||
data: { name?: string; icon?: string },
|
data: { name?: string; icon?: string },
|
||||||
) {
|
) {
|
||||||
const existing = db
|
const [existing] = await db
|
||||||
.select({ id: categories.id })
|
.select({ id: categories.id })
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.id, id))
|
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return db
|
const [row] = await db
|
||||||
.update(categories)
|
.update(categories)
|
||||||
.set(data)
|
.set(data)
|
||||||
.where(eq(categories.id, id))
|
.where(and(eq(categories.id, id), eq(categories.userId, userId)))
|
||||||
.returning()
|
.returning();
|
||||||
.get();
|
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCategory(
|
export async function deleteCategory(
|
||||||
db: Db = prodDb,
|
db: Db,
|
||||||
|
userId: number,
|
||||||
id: number,
|
id: number,
|
||||||
): { success: boolean; error?: string } {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
// Guard: cannot delete Uncategorized (id=1)
|
// Check if this is the Uncategorized category for this user
|
||||||
if (id === 1) {
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Cannot delete the Uncategorized category",
|
error: "Cannot delete the Uncategorized category",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if category exists
|
// Get or create Uncategorized for this user (dynamic, not hardcoded ID)
|
||||||
const existing = db
|
const uncategorizedId = await getOrCreateUncategorized(db, userId);
|
||||||
.select({ id: categories.id })
|
|
||||||
.from(categories)
|
|
||||||
.where(eq(categories.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!existing) {
|
// Reassign this user's items to Uncategorized, then delete atomically
|
||||||
return { success: false, error: "Category not found" };
|
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
|
await tx
|
||||||
db.transaction(() => {
|
.delete(categories)
|
||||||
db.update(items)
|
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
|
||||||
.set({ categoryId: 1 })
|
|
||||||
.where(eq(items.categoryId, id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
db.delete(categories).where(eq(categories.id, id)).run();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
import { categories, items } from "../../db/schema.ts";
|
import { categories, items } from "../../db/schema.ts";
|
||||||
|
import { getOrCreateUncategorized } from "./category.service.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
|||||||
|
|
||||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function exportItemsCsv(db: Db = prodDb): 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: items.name,
|
||||||
@@ -96,7 +97,8 @@ export async function exportItemsCsv(db: Db = prodDb): Promise<string> {
|
|||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id));
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.where(eq(items.userId, userId));
|
||||||
|
|
||||||
const header =
|
const header =
|
||||||
"name,quantity,weightGrams,priceCents,category,notes,productUrl";
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl";
|
||||||
@@ -124,7 +126,8 @@ export interface ImportResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importItemsCsv(
|
export async function importItemsCsv(
|
||||||
db: Db = prodDb,
|
db: Db,
|
||||||
|
userId: number,
|
||||||
csvContent: string,
|
csvContent: string,
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
const { headers, rows } = parseCsv(csvContent);
|
const { headers, rows } = parseCsv(csvContent);
|
||||||
@@ -149,11 +152,12 @@ export async function importItemsCsv(
|
|||||||
const notesIdx = headerIndex("notes");
|
const notesIdx = headerIndex("notes");
|
||||||
const urlIdx = headerIndex("productUrl");
|
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<string, number>();
|
const categoryCache = new Map<string, number>();
|
||||||
const existingCategories = await db
|
const existingCategories = await db
|
||||||
.select({ id: categories.id, name: categories.name })
|
.select({ id: categories.id, name: categories.name })
|
||||||
.from(categories);
|
.from(categories)
|
||||||
|
.where(eq(categories.userId, userId));
|
||||||
for (const cat of existingCategories) {
|
for (const cat of existingCategories) {
|
||||||
categoryCache.set(cat.name.toLowerCase(), cat.id);
|
categoryCache.set(cat.name.toLowerCase(), cat.id);
|
||||||
}
|
}
|
||||||
@@ -169,7 +173,7 @@ export async function importItemsCsv(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category resolution
|
// Category resolution — scoped to this user
|
||||||
let categoryId: number;
|
let categoryId: number;
|
||||||
const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : "";
|
const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : "";
|
||||||
const categoryName = rawCategory || "Uncategorized";
|
const categoryName = rawCategory || "Uncategorized";
|
||||||
@@ -177,11 +181,15 @@ export async function importItemsCsv(
|
|||||||
|
|
||||||
if (categoryCache.has(cacheKey)) {
|
if (categoryCache.has(cacheKey)) {
|
||||||
categoryId = categoryCache.get(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 {
|
} else {
|
||||||
// Create a new category
|
// Create a new category for this user
|
||||||
const [inserted] = await db
|
const [inserted] = await db
|
||||||
.insert(categories)
|
.insert(categories)
|
||||||
.values({ name: categoryName, icon: "package" })
|
.values({ name: categoryName, icon: "package", userId })
|
||||||
.returning();
|
.returning();
|
||||||
categoryId = inserted.id;
|
categoryId = inserted.id;
|
||||||
categoryCache.set(cacheKey, categoryId);
|
categoryCache.set(cacheKey, categoryId);
|
||||||
@@ -225,6 +233,7 @@ export async function importItemsCsv(
|
|||||||
weightGrams,
|
weightGrams,
|
||||||
priceCents,
|
priceCents,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
userId,
|
||||||
notes,
|
notes,
|
||||||
productUrl,
|
productUrl,
|
||||||
imageFilename: null,
|
imageFilename: null,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
import { categories, items } from "../../db/schema.ts";
|
import { categories, 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;
|
||||||
|
|
||||||
export async function getAllItems(db: Db = prodDb) {
|
export async function getAllItems(db: Db, userId: number) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
id: items.id,
|
id: items.id,
|
||||||
@@ -24,10 +24,11 @@ export async function getAllItems(db: Db = prodDb) {
|
|||||||
categoryIcon: categories.icon,
|
categoryIcon: categories.icon,
|
||||||
})
|
})
|
||||||
.from(items)
|
.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
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
id: items.id,
|
id: items.id,
|
||||||
@@ -43,13 +44,14 @@ export async function getItemById(db: Db = prodDb, id: number) {
|
|||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
.where(eq(items.id, id));
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createItem(
|
export async function createItem(
|
||||||
db: Db = prodDb,
|
db: Db,
|
||||||
|
userId: number,
|
||||||
data: Partial<CreateItem> & {
|
data: Partial<CreateItem> & {
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
@@ -64,6 +66,7 @@ export async function createItem(
|
|||||||
priceCents: data.priceCents ?? null,
|
priceCents: data.priceCents ?? null,
|
||||||
quantity: data.quantity ?? 1,
|
quantity: data.quantity ?? 1,
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
|
userId,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
productUrl: data.productUrl ?? null,
|
productUrl: data.productUrl ?? null,
|
||||||
imageFilename: data.imageFilename ?? null,
|
imageFilename: data.imageFilename ?? null,
|
||||||
@@ -75,7 +78,8 @@ export async function createItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateItem(
|
export async function updateItem(
|
||||||
db: Db = prodDb,
|
db: Db,
|
||||||
|
userId: number,
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -89,25 +93,28 @@ export async function updateItem(
|
|||||||
imageSourceUrl: string;
|
imageSourceUrl: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
// Check if item exists first
|
// Check if item exists and belongs to user
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: items.id })
|
.select({ id: items.id })
|
||||||
.from(items)
|
.from(items)
|
||||||
.where(eq(items.id, id));
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(items)
|
.update(items)
|
||||||
.set({ ...data, updatedAt: new Date() })
|
.set({ ...data, updatedAt: new Date() })
|
||||||
.where(eq(items.id, id))
|
.where(and(eq(items.id, id), eq(items.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicateItem(db: Db = prodDb, id: number) {
|
export async function duplicateItem(db: Db, userId: number, id: number) {
|
||||||
const [source] = await db.select().from(items).where(eq(items.id, id));
|
const [source] = await db
|
||||||
|
.select()
|
||||||
|
.from(items)
|
||||||
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
@@ -118,6 +125,7 @@ export async function duplicateItem(db: Db = prodDb, id: number) {
|
|||||||
weightGrams: source.weightGrams,
|
weightGrams: source.weightGrams,
|
||||||
priceCents: source.priceCents,
|
priceCents: source.priceCents,
|
||||||
categoryId: source.categoryId,
|
categoryId: source.categoryId,
|
||||||
|
userId,
|
||||||
notes: source.notes,
|
notes: source.notes,
|
||||||
productUrl: source.productUrl,
|
productUrl: source.productUrl,
|
||||||
imageFilename: source.imageFilename,
|
imageFilename: source.imageFilename,
|
||||||
@@ -129,13 +137,18 @@ export async function duplicateItem(db: Db = prodDb, id: number) {
|
|||||||
return row;
|
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)
|
// 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;
|
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;
|
return item;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { db as prodDb } from "../../db/index.ts";
|
||||||
import { categories, items } from "../../db/schema.ts";
|
import { categories, items } from "../../db/schema.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
export async function getCategoryTotals(db: Db = prodDb) {
|
export async function getCategoryTotals(db: Db, userId: number) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
@@ -16,17 +16,19 @@ export async function getCategoryTotals(db: Db = prodDb) {
|
|||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.where(eq(items.userId, userId))
|
||||||
.groupBy(items.categoryId, categories.name, categories.icon);
|
.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
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
||||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
||||||
itemCount: sql<number>`COUNT(*)`,
|
itemCount: sql<number>`COUNT(*)`,
|
||||||
})
|
})
|
||||||
.from(items);
|
.from(items)
|
||||||
|
.where(eq(items.userId, userId));
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user