import { and, asc, desc, eq, max, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items, threadCandidates, threads, } from "../../db/schema.ts"; import type { CreateCandidate, CreateThread, ReorderCandidates, } from "../../shared/types.ts"; import { getOrCreateUncategorized } from "./category.service.ts"; type Db = typeof prodDb; export async function createThread( db: Db, userId: number, data: CreateThread, ) { const [row] = await db .insert(threads) .values({ name: data.name, categoryId: data.categoryId, userId }) .returning(); return row; } 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, status: threads.status, resolvedCandidateId: threads.resolvedCandidateId, categoryId: threads.categoryId, categoryName: categories.name, categoryIcon: categories.icon, createdAt: threads.createdAt, updatedAt: threads.updatedAt, candidateCount: sql`( SELECT COUNT(*) FROM thread_candidates WHERE thread_candidates.thread_id = threads.id )`.as("candidate_count"), minPriceCents: sql`( SELECT MIN(price_cents) FROM thread_candidates WHERE thread_candidates.thread_id = threads.id )`.as("min_price_cents"), maxPriceCents: sql`( SELECT MAX(price_cents) FROM thread_candidates WHERE thread_candidates.thread_id = threads.id )`.as("max_price_cents"), }) .from(threads) .innerJoin(categories, eq(threads.categoryId, categories.id)) .where(whereCondition) .orderBy(desc(threads.createdAt)); } export async function getThreadWithCandidates( db: Db, userId: number, threadId: number, ) { const [thread] = await db .select() .from(threads) .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) return null; const candidateList = await db .select({ id: threadCandidates.id, threadId: threadCandidates.threadId, name: threadCandidates.name, weightGrams: threadCandidates.weightGrams, priceCents: threadCandidates.priceCents, categoryId: threadCandidates.categoryId, notes: threadCandidates.notes, productUrl: threadCandidates.productUrl, imageFilename: threadCandidates.imageFilename, imageSourceUrl: threadCandidates.imageSourceUrl, status: threadCandidates.status, pros: threadCandidates.pros, cons: threadCandidates.cons, createdAt: threadCandidates.createdAt, updatedAt: threadCandidates.updatedAt, categoryName: categories.name, categoryIcon: categories.icon, }) .from(threadCandidates) .innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) .where(eq(threadCandidates.threadId, threadId)) .orderBy(asc(threadCandidates.sortOrder)); return { ...thread, candidates: candidateList }; } export async function updateThread( db: Db, userId: number, threadId: number, data: Partial<{ name: string; categoryId: number }>, ) { const [existing] = await db .select({ id: threads.id }) .from(threads) .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(and(eq(threads.id, threadId), eq(threads.userId, userId))) .returning(); return row; } export async function deleteThread(db: Db, userId: number, threadId: number) { const [thread] = await db .select() .from(threads) .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) return null; // Collect candidate image filenames for cleanup const candidatesWithImages = ( await db .select({ imageFilename: threadCandidates.imageFilename }) .from(threadCandidates) .where(eq(threadCandidates.threadId, threadId)) ).filter((c) => c.imageFilename != null); await db .delete(threads) .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!), }; } export async function createCandidate( db: Db, userId: number, threadId: number, data: Partial & { name: string; categoryId: number; imageFilename?: string; 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) .where(eq(threadCandidates.threadId, threadId)); const nextSortOrder = (maxRow?.maxOrder ?? 0) + 1000; const [row] = await db .insert(threadCandidates) .values({ threadId, 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, imageSourceUrl: data.imageSourceUrl ?? null, status: data.status ?? "researching", pros: data.pros ?? null, cons: data.cons ?? null, sortOrder: nextSortOrder, }) .returning(); return row; } export async function updateCandidate( db: Db, userId: number, candidateId: number, data: Partial<{ name: string; weightGrams: number; priceCents: number; categoryId: number; notes: string; productUrl: string; imageFilename: string; imageSourceUrl: string; status: "researching" | "ordered" | "arrived"; pros: string; cons: string; }>, ) { // Verify the candidate's parent thread belongs to this user const [existing] = await db .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() }) .where(eq(threadCandidates.id, candidateId)) .returning(); return row; } 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, userId: number, threadId: number, orderedIds: ReorderCandidates["orderedIds"], ): Promise<{ success: boolean; error?: string }> { return await db.transaction(async (tx) => { const [thread] = await tx .select() .from(threads) .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread) { return { success: false, error: "Thread not found" }; } if (thread.status !== "active") { return { success: false, error: "Thread not active" }; } for (let i = 0; i < orderedIds.length; i++) { await tx .update(threadCandidates) .set({ sortOrder: (i + 1) * 1000 }) .where(eq(threadCandidates.id, orderedIds[i])); } return { success: true }; }); } export async function resolveThread( 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 and belongs to user const [thread] = await tx .select() .from(threads) .where(and(eq(threads.id, threadId), eq(threads.userId, userId))); if (!thread || thread.status !== "active") { return { success: false, error: "Thread not active" }; } // 2. Get the candidate data const [candidate] = await tx .select() .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)); if (!candidate) { return { success: false, error: "Candidate not found" }; } if (candidate.threadId !== threadId) { return { success: false, error: "Candidate not in thread" }; } // 3. Verify categoryId belongs to user, fallback to Uncategorized const [category] = await tx .select({ id: categories.id }) .from(categories) .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 — with userId const [newItem] = await tx .insert(items) .values({ name: candidate.name, weightGrams: candidate.weightGrams, priceCents: candidate.priceCents, categoryId: safeCategoryId, userId, notes: candidate.notes, productUrl: candidate.productUrl, imageFilename: candidate.imageFilename, imageSourceUrl: candidate.imageSourceUrl, quantity: 1, }) .returning(); // 5. Archive the thread await tx .update(threads) .set({ status: "resolved", resolvedCandidateId: candidateId, updatedAt: new Date(), }) .where(eq(threads.id, threadId)); return { success: true, item: newItem }; }); }