import { desc, eq, 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 } from "../../shared/types.ts"; type Db = typeof prodDb; export function createThread(db: Db = prodDb, data: CreateThread) { return db .insert(threads) .values({ name: data.name, categoryId: data.categoryId }) .returning() .get(); } export function getAllThreads(db: Db = prodDb, includeResolved = false) { const query = 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)) .orderBy(desc(threads.createdAt)); if (!includeResolved) { return query.where(eq(threads.status, "active")).all(); } return query.all(); } export function getThreadWithCandidates(db: Db = prodDb, threadId: number) { const thread = db .select() .from(threads) .where(eq(threads.id, threadId)) .get(); if (!thread) return null; const candidateList = 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, status: threadCandidates.status, 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)) .all(); return { ...thread, candidates: candidateList }; } export function updateThread( db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>, ) { const existing = db .select({ id: threads.id }) .from(threads) .where(eq(threads.id, threadId)) .get(); if (!existing) return null; return db .update(threads) .set({ ...data, updatedAt: new Date() }) .where(eq(threads.id, threadId)) .returning() .get(); } export function deleteThread(db: Db = prodDb, threadId: number) { const thread = db .select() .from(threads) .where(eq(threads.id, threadId)) .get(); if (!thread) return null; // Collect candidate image filenames for cleanup const candidatesWithImages = db .select({ imageFilename: threadCandidates.imageFilename }) .from(threadCandidates) .where(eq(threadCandidates.threadId, threadId)) .all() .filter((c) => c.imageFilename != null); db.delete(threads).where(eq(threads.id, threadId)).run(); return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!), }; } export function createCandidate( db: Db = prodDb, threadId: number, data: Partial & { name: string; categoryId: number; imageFilename?: string; }, ) { return 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, status: data.status ?? "researching", }) .returning() .get(); } export function updateCandidate( db: Db = prodDb, candidateId: number, data: Partial<{ name: string; weightGrams: number; priceCents: number; categoryId: number; notes: string; productUrl: string; imageFilename: string; status: "researching" | "ordered" | "arrived"; }>, ) { const existing = db .select({ id: threadCandidates.id }) .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)) .get(); if (!existing) return null; return db .update(threadCandidates) .set({ ...data, updatedAt: new Date() }) .where(eq(threadCandidates.id, candidateId)) .returning() .get(); } export function deleteCandidate(db: Db = prodDb, candidateId: number) { const candidate = db .select() .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)) .get(); if (!candidate) return null; db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run(); return candidate; } export function resolveThread( db: Db = prodDb, threadId: number, candidateId: number, ): { success: boolean; item?: any; error?: string } { return db.transaction((tx) => { // 1. Check thread is active const thread = tx .select() .from(threads) .where(eq(threads.id, threadId)) .get(); if (!thread || thread.status !== "active") { return { success: false, error: "Thread not active" }; } // 2. Get the candidate data const candidate = tx .select() .from(threadCandidates) .where(eq(threadCandidates.id, candidateId)) .get(); if (!candidate) { return { success: false, error: "Candidate not found" }; } if (candidate.threadId !== threadId) { return { success: false, error: "Candidate not in thread" }; } // 3. Verify categoryId still exists, fallback to Uncategorized (id=1) const category = tx .select({ id: categories.id }) .from(categories) .where(eq(categories.id, candidate.categoryId)) .get(); const safeCategoryId = category ? candidate.categoryId : 1; // 4. Create collection item from candidate data const newItem = tx .insert(items) .values({ name: candidate.name, weightGrams: candidate.weightGrams, priceCents: candidate.priceCents, categoryId: safeCategoryId, notes: candidate.notes, productUrl: candidate.productUrl, imageFilename: candidate.imageFilename, }) .returning() .get(); // 5. Archive the thread tx.update(threads) .set({ status: "resolved", resolvedCandidateId: candidateId, updatedAt: new Date(), }) .where(eq(threads.id, threadId)) .run(); return { success: true, item: newItem }; }); }