feat(02-01): implement thread service with CRUD and transactional resolution
- Thread CRUD: create, update, delete with cascade candidate cleanup - Candidate CRUD: create, update, delete with all item-compatible fields - getAllThreads with subquery aggregates for candidateCount and price range - getThreadWithCandidates with candidate+category join - resolveThread: atomic transaction creating collection item from candidate data - Category fallback to Uncategorized on resolution if category deleted Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
217
src/server/services/thread.service.ts
Normal file
217
src/server/services/thread.service.ts
Normal file
@@ -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<number>`(
|
||||||
|
SELECT COUNT(*) FROM thread_candidates
|
||||||
|
WHERE thread_candidates.thread_id = threads.id
|
||||||
|
)`.as("candidate_count"),
|
||||||
|
minPriceCents: sql<number | null>`(
|
||||||
|
SELECT MIN(price_cents) FROM thread_candidates
|
||||||
|
WHERE thread_candidates.thread_id = threads.id
|
||||||
|
)`.as("min_price_cents"),
|
||||||
|
maxPriceCents: sql<number | null>`(
|
||||||
|
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<CreateCandidate> & { 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user