- All functions accept userId, no more prodDb defaults
- Thread operations verify ownership via and(eq(id), eq(userId))
- Candidate operations verify parent thread ownership before proceeding
- resolveThread includes userId in new item insert and verifies category ownership
- Setup operations use and() for composite id+userId conditions
- syncSetupItems validates both setup and item ownership via inArray
- updateItemClassification and removeSetupItem verify setup ownership
- Auth service: reordered createApiKey params to (db, userId, name)
- verifyApiKey unchanged (already returns { userId } from Plan 01)
371 lines
9.6 KiB
TypeScript
371 lines
9.6 KiB
TypeScript
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<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)
|
|
.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<CreateCandidate> & {
|
|
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 };
|
|
});
|
|
}
|