feat(11-01): schema, service, and tests for sort_order + reorderCandidates

- Add sortOrder REAL column to threadCandidates schema (default 0)
- Add sort_order column to test helper CREATE TABLE
- Add reorderCandidatesSchema to shared/schemas.ts
- Add ReorderCandidates type to shared/types.ts
- getThreadWithCandidates now orders candidates by sort_order ASC
- createCandidate appends at max sort_order + 1000 (first = 1000)
- Add reorderCandidates service function (transaction, active-only guard)
- Add 5 new tests: ordering, appending, reorder success, resolved guard, missing thread
This commit is contained in:
2026-03-16 22:21:42 +01:00
parent 2986bdd2e5
commit f01d71d6b4
9 changed files with 656 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { desc, eq, sql } from "drizzle-orm";
import { asc, desc, eq, max, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import {
categories,
@@ -6,7 +6,11 @@ import {
threadCandidates,
threads,
} from "../../db/schema.ts";
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
import type {
CreateCandidate,
CreateThread,
ReorderCandidates,
} from "../../shared/types.ts";
type Db = typeof prodDb;
@@ -83,6 +87,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
.from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.where(eq(threadCandidates.threadId, threadId))
.orderBy(asc(threadCandidates.sortOrder))
.all();
return { ...thread, candidates: candidateList };
@@ -141,6 +146,14 @@ export function createCandidate(
imageFilename?: string;
},
) {
const maxRow = db
.select({ maxOrder: max(threadCandidates.sortOrder) })
.from(threadCandidates)
.where(eq(threadCandidates.threadId, threadId))
.get();
const nextSortOrder = (maxRow?.maxOrder ?? 0) + 1000;
return db
.insert(threadCandidates)
.values({
@@ -155,6 +168,7 @@ export function createCandidate(
status: data.status ?? "researching",
pros: data.pros ?? null,
cons: data.cons ?? null,
sortOrder: nextSortOrder,
})
.returning()
.get();
@@ -203,6 +217,35 @@ export function deleteCandidate(db: Db = prodDb, candidateId: number) {
return candidate;
}
export function reorderCandidates(
db: Db = prodDb,
threadId: number,
orderedIds: ReorderCandidates["orderedIds"],
): { success: boolean; error?: string } {
return db.transaction((tx) => {
const thread = tx
.select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
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++) {
tx.update(threadCandidates)
.set({ sortOrder: (i + 1) * 1000 })
.where(eq(threadCandidates.id, orderedIds[i]))
.run();
}
return { success: true };
});
}
export function resolveThread(
db: Db = prodDb,
threadId: number,