diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts new file mode 100644 index 0000000..6362480 --- /dev/null +++ b/src/server/services/thread.service.ts @@ -0,0 +1,217 @@ +import { eq, desc, sql } from "drizzle-orm"; +import { threads, threadCandidates, items, categories } from "../../db/schema.ts"; +import { db as prodDb } from "../../db/index.ts"; +import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts"; + +type Db = typeof prodDb; + +export function createThread(db: Db = prodDb, data: CreateThread) { + return db + .insert(threads) + .values({ name: data.name }) + .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, + 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) + .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, + createdAt: threadCandidates.createdAt, + updatedAt: threadCandidates.updatedAt, + categoryName: categories.name, + categoryEmoji: categories.emoji, + }) + .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 }>) { + 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, + }) + .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; + }>, +) { + 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 }; + }); +}