Files
GearBox/src/server/services/thread.service.ts
Jean-Luc Makiola 242cacea7c feat(16-02): add userId scoping to thread, setup, and auth services
- 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)
2026-04-05 10:43:38 +02:00

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 };
});
}