diff --git a/src/server/services/auth.service.ts b/src/server/services/auth.service.ts index 5a800e6..b5c9ced 100644 --- a/src/server/services/auth.service.ts +++ b/src/server/services/auth.service.ts @@ -25,9 +25,9 @@ export async function getOrCreateUser( // ── API Key Management ─────────────────────────────────────────────── export async function createApiKey( - db: Db = prodDb, - name: string, + db: Db, userId: number, + name: string, ) { const rawKey = randomBytes(32).toString("hex"); const keyHash = await Bun.password.hash(rawKey); @@ -42,7 +42,7 @@ export async function createApiKey( } export async function verifyApiKey( - db: Db = prodDb, + db: Db, rawKey: string, ): Promise<{ userId: number } | null> { const prefix = rawKey.slice(0, 8); @@ -59,7 +59,7 @@ export async function verifyApiKey( return null; } -export async function listApiKeys(db: Db = prodDb, userId: number) { +export async function listApiKeys(db: Db, userId: number) { return db .select({ id: apiKeys.id, @@ -71,11 +71,7 @@ export async function listApiKeys(db: Db = prodDb, userId: number) { .where(eq(apiKeys.userId, userId)); } -export async function deleteApiKey( - db: Db = prodDb, - id: number, - userId: number, -) { +export async function deleteApiKey(db: Db, userId: number, id: number) { await db .delete(apiKeys) .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))); diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index 49aef9d..da0456f 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -1,17 +1,24 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items, setupItems, setups } from "../../db/schema.ts"; import type { CreateSetup, UpdateSetup } from "../../shared/types.ts"; type Db = typeof prodDb; -export async function createSetup(db: Db = prodDb, data: CreateSetup) { - const [row] = await db.insert(setups).values({ name: data.name }).returning(); +export async function createSetup( + db: Db, + userId: number, + data: CreateSetup, +) { + const [row] = await db + .insert(setups) + .values({ name: data.name, userId }) + .returning(); return row; } -export async function getAllSetups(db: Db = prodDb) { +export async function getAllSetups(db: Db, userId: number) { return db .select({ id: setups.id, @@ -33,11 +40,19 @@ export async function getAllSetups(db: Db = prodDb) { WHERE setup_items.setup_id = setups.id ), 0)`.as("total_cost"), }) - .from(setups); + .from(setups) + .where(eq(setups.userId, userId)); } -export async function getSetupWithItems(db: Db = prodDb, setupId: number) { - const [setup] = await db.select().from(setups).where(eq(setups.id, setupId)); +export async function getSetupWithItems( + db: Db, + userId: number, + setupId: number, +) { + const [setup] = await db + .select() + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!setup) return null; const itemList = await db @@ -66,42 +81,68 @@ export async function getSetupWithItems(db: Db = prodDb, setupId: number) { } export async function updateSetup( - db: Db = prodDb, + db: Db, + userId: number, setupId: number, data: UpdateSetup, ) { const [existing] = await db .select({ id: setups.id }) .from(setups) - .where(eq(setups.id, setupId)); + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!existing) return null; const [row] = await db .update(setups) .set({ name: data.name, updatedAt: new Date() }) - .where(eq(setups.id, setupId)) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))) .returning(); return row; } -export async function deleteSetup(db: Db = prodDb, setupId: number) { +export async function deleteSetup( + db: Db, + userId: number, + setupId: number, +) { const [existing] = await db .select({ id: setups.id }) .from(setups) - .where(eq(setups.id, setupId)); + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); if (!existing) return false; - await db.delete(setups).where(eq(setups.id, setupId)); + await db + .delete(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); return true; } export async function syncSetupItems( - db: Db = prodDb, + db: Db, + userId: number, setupId: number, itemIds: number[], ) { return await db.transaction(async (tx) => { + // Verify the setup belongs to this user + const [setup] = await tx + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + + // Verify all itemIds belong to this user + const validItems = + itemIds.length > 0 + ? await tx + .select({ id: items.id }) + .from(items) + .where(and(eq(items.userId, userId), inArray(items.id, itemIds))) + : []; + const validItemIds = new Set(validItems.map((i) => i.id)); + const filteredItemIds = itemIds.filter((id) => validItemIds.has(id)); + // Save existing classifications before deleting const existing = await tx .select({ @@ -119,8 +160,8 @@ export async function syncSetupItems( // Delete all existing items for this setup await tx.delete(setupItems).where(eq(setupItems.setupId, setupId)); - // Re-insert new items, preserving classifications for retained items - for (const itemId of itemIds) { + // Re-insert only user-owned items, preserving classifications + for (const itemId of filteredItemIds) { await tx.insert(setupItems).values({ setupId, itemId, @@ -131,27 +172,43 @@ export async function syncSetupItems( } export async function updateItemClassification( - db: Db = prodDb, + db: Db, + userId: number, setupId: number, itemId: number, classification: string, ) { + // Verify setup belongs to user + const [setup] = await db + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + await db .update(setupItems) .set({ classification }) .where( - sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, + and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)), ); } export async function removeSetupItem( - db: Db = prodDb, + db: Db, + userId: number, setupId: number, itemId: number, ) { + // Verify setup belongs to user + const [setup] = await db + .select({ id: setups.id }) + .from(setups) + .where(and(eq(setups.id, setupId), eq(setups.userId, userId))); + if (!setup) return null; + await db .delete(setupItems) .where( - sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, + and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)), ); } diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index e38e778..ad7383b 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -1,4 +1,4 @@ -import { asc, desc, eq, max, sql } from "drizzle-orm"; +import { and, asc, desc, eq, max, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, @@ -11,20 +11,34 @@ import type { CreateThread, ReorderCandidates, } from "../../shared/types.ts"; +import { getOrCreateUncategorized } from "./category.service.ts"; type Db = typeof prodDb; -export async function createThread(db: Db = prodDb, data: CreateThread) { +export async function createThread( + db: Db, + userId: number, + data: CreateThread, +) { const [row] = await db .insert(threads) - .values({ name: data.name, categoryId: data.categoryId }) + .values({ name: data.name, categoryId: data.categoryId, userId }) .returning(); return row; } -export async function getAllThreads(db: Db = prodDb, includeResolved = false) { - const query = db +export async function getAllThreads( + db: Db, + userId: number, + includeResolved = false, +) { + const baseCondition = eq(threads.userId, userId); + const whereCondition = includeResolved + ? baseCondition + : and(baseCondition, eq(threads.status, "active")); + + return db .select({ id: threads.id, name: threads.name, @@ -50,22 +64,19 @@ export async function getAllThreads(db: Db = prodDb, includeResolved = false) { }) .from(threads) .innerJoin(categories, eq(threads.categoryId, categories.id)) + .where(whereCondition) .orderBy(desc(threads.createdAt)); - - if (!includeResolved) { - return query.where(eq(threads.status, "active")); - } - return query; } export async function getThreadWithCandidates( - db: Db = prodDb, + db: Db, + userId: number, threadId: number, ) { const [thread] = await db .select() .from(threads) - .where(eq(threads.id, threadId)); + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) return null; const candidateList = await db @@ -97,30 +108,31 @@ export async function getThreadWithCandidates( } export async function updateThread( - db: Db = prodDb, + db: Db, + userId: number, threadId: number, data: Partial<{ name: string; categoryId: number }>, ) { const [existing] = await db .select({ id: threads.id }) .from(threads) - .where(eq(threads.id, threadId)); + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!existing) return null; const [row] = await db .update(threads) .set({ ...data, updatedAt: new Date() }) - .where(eq(threads.id, threadId)) + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))) .returning(); return row; } -export async function deleteThread(db: Db = prodDb, threadId: number) { +export async function deleteThread(db: Db, userId: number, threadId: number) { const [thread] = await db .select() .from(threads) - .where(eq(threads.id, threadId)); + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) return null; // Collect candidate image filenames for cleanup @@ -131,7 +143,9 @@ export async function deleteThread(db: Db = prodDb, threadId: number) { .where(eq(threadCandidates.threadId, threadId)) ).filter((c) => c.imageFilename != null); - await db.delete(threads).where(eq(threads.id, threadId)); + await db + .delete(threads) + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); return { ...thread, @@ -140,7 +154,8 @@ export async function deleteThread(db: Db = prodDb, threadId: number) { } export async function createCandidate( - db: Db = prodDb, + db: Db, + userId: number, threadId: number, data: Partial & { name: string; @@ -149,6 +164,13 @@ export async function createCandidate( imageSourceUrl?: string; }, ) { + // Verify the parent thread belongs to this user + const [thread] = await db + .select({ id: threads.id }) + .from(threads) + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); + if (!thread) return null; + const [maxRow] = await db .select({ maxOrder: max(threadCandidates.sortOrder) }) .from(threadCandidates) @@ -179,7 +201,8 @@ export async function createCandidate( } export async function updateCandidate( - db: Db = prodDb, + db: Db, + userId: number, candidateId: number, data: Partial<{ name: string; @@ -195,12 +218,22 @@ export async function updateCandidate( cons: string; }>, ) { + // Verify the candidate's parent thread belongs to this user const [existing] = await db - .select({ id: threadCandidates.id }) + .select({ + id: threadCandidates.id, + threadId: threadCandidates.threadId, + }) .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)); if (!existing) return null; + const [thread] = await db + .select({ id: threads.id }) + .from(threads) + .where(and(eq(threads.id, existing.threadId), eq(threads.userId, userId))); + if (!thread) return null; + const [row] = await db .update(threadCandidates) .set({ ...data, updatedAt: new Date() }) @@ -210,19 +243,33 @@ export async function updateCandidate( return row; } -export async function deleteCandidate(db: Db = prodDb, candidateId: number) { +export async function deleteCandidate( + db: Db, + userId: number, + candidateId: number, +) { + // Verify the candidate's parent thread belongs to this user const [candidate] = await db .select() .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)); if (!candidate) return null; + const [thread] = await db + .select({ id: threads.id }) + .from(threads) + .where( + and(eq(threads.id, candidate.threadId), eq(threads.userId, userId)), + ); + if (!thread) return null; + await db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)); return candidate; } export async function reorderCandidates( - db: Db = prodDb, + db: Db, + userId: number, threadId: number, orderedIds: ReorderCandidates["orderedIds"], ): Promise<{ success: boolean; error?: string }> { @@ -230,7 +277,7 @@ export async function reorderCandidates( const [thread] = await tx .select() .from(threads) - .where(eq(threads.id, threadId)); + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) { return { success: false, error: "Thread not found" }; } @@ -250,16 +297,17 @@ export async function reorderCandidates( } export async function resolveThread( - db: Db = prodDb, + db: Db, + userId: number, threadId: number, candidateId: number, ): Promise<{ success: boolean; item?: any; error?: string }> { return await db.transaction(async (tx) => { - // 1. Check thread is active + // 1. Check thread is active and belongs to user const [thread] = await tx .select() .from(threads) - .where(eq(threads.id, threadId)); + .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread || thread.status !== "active") { return { success: false, error: "Thread not active" }; } @@ -276,14 +324,21 @@ export async function resolveThread( return { success: false, error: "Candidate not in thread" }; } - // 3. Verify categoryId still exists, fallback to Uncategorized (id=1) + // 3. Verify categoryId belongs to user, fallback to Uncategorized const [category] = await tx .select({ id: categories.id }) .from(categories) - .where(eq(categories.id, candidate.categoryId)); - const safeCategoryId = category ? candidate.categoryId : 1; + .where( + and( + eq(categories.id, candidate.categoryId), + eq(categories.userId, userId), + ), + ); + const safeCategoryId = category + ? candidate.categoryId + : await getOrCreateUncategorized(tx as unknown as Db, userId); - // 4. Create collection item from candidate data + // 4. Create collection item from candidate data — with userId const [newItem] = await tx .insert(items) .values({ @@ -291,6 +346,7 @@ export async function resolveThread( weightGrams: candidate.weightGrams, priceCents: candidate.priceCents, categoryId: safeCategoryId, + userId, notes: candidate.notes, productUrl: candidate.productUrl, imageFilename: candidate.imageFilename,