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

@@ -0,0 +1 @@
ALTER TABLE `thread_candidates` ADD `sort_order` real DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,505 @@
{
"version": "6",
"dialect": "sqlite",
"id": "297e86db-c777-4432-950e-b0129dedb2dc",
"prevId": "529d2e93-7d7f-4a83-bb29-254ce09cdef4",
"tables": {
"categories": {
"name": "categories",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'package'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"categories_name_unique": {
"name": "categories_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"items_category_id_categories_id_fk": {
"name": "items_category_id_categories_id_fk",
"tableFrom": "items",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_items": {
"name": "setup_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"setup_id": {
"name": "setup_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"classification": {
"name": "classification",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'base'"
}
},
"indexes": {},
"foreignKeys": {
"setup_items_setup_id_setups_id_fk": {
"name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items",
"tableTo": "setups",
"columnsFrom": [
"setup_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"setup_items_item_id_items_id_fk": {
"name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setups": {
"name": "setups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"thread_candidates": {
"name": "thread_candidates",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"thread_id": {
"name": "thread_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'researching'"
},
"pros": {
"name": "pros",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cons": {
"name": "cons",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"thread_candidates_thread_id_threads_id_fk": {
"name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "threads",
"columnsFrom": [
"thread_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"thread_candidates_category_id_categories_id_fk": {
"name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"threads": {
"name": "threads",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"resolved_candidate_id": {
"name": "resolved_candidate_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"threads_category_id_categories_id_fk": {
"name": "threads_category_id_categories_id_fk",
"tableFrom": "threads",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1773693113029, "when": 1773693113029,
"tag": "0004_soft_synch", "tag": "0004_soft_synch",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773696058992,
"tag": "0005_clear_micromax",
"breakpoints": true
} }
] ]
} }

View File

@@ -61,6 +61,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
status: text("status").notNull().default("researching"), status: text("status").notNull().default("researching"),
pros: text("pros"), pros: text("pros"),
cons: text("cons"), cons: text("cons"),
sortOrder: real("sort_order").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),

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 { db as prodDb } from "../../db/index.ts";
import { import {
categories, categories,
@@ -6,7 +6,11 @@ import {
threadCandidates, threadCandidates,
threads, threads,
} from "../../db/schema.ts"; } 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; type Db = typeof prodDb;
@@ -83,6 +87,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
.from(threadCandidates) .from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.where(eq(threadCandidates.threadId, threadId)) .where(eq(threadCandidates.threadId, threadId))
.orderBy(asc(threadCandidates.sortOrder))
.all(); .all();
return { ...thread, candidates: candidateList }; return { ...thread, candidates: candidateList };
@@ -141,6 +146,14 @@ export function createCandidate(
imageFilename?: string; 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 return db
.insert(threadCandidates) .insert(threadCandidates)
.values({ .values({
@@ -155,6 +168,7 @@ export function createCandidate(
status: data.status ?? "researching", status: data.status ?? "researching",
pros: data.pros ?? null, pros: data.pros ?? null,
cons: data.cons ?? null, cons: data.cons ?? null,
sortOrder: nextSortOrder,
}) })
.returning() .returning()
.get(); .get();
@@ -203,6 +217,35 @@ export function deleteCandidate(db: Db = prodDb, candidateId: number) {
return candidate; 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( export function resolveThread(
db: Db = prodDb, db: Db = prodDb,
threadId: number, threadId: number,

View File

@@ -63,6 +63,10 @@ export const resolveThreadSchema = z.object({
candidateId: z.number().int().positive(), candidateId: z.number().int().positive(),
}); });
export const reorderCandidatesSchema = z.object({
orderedIds: z.array(z.number().int().positive()).min(1),
});
// Setup schemas // Setup schemas
export const createSetupSchema = z.object({ export const createSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"), name: z.string().min(1, "Setup name is required"),

View File

@@ -13,6 +13,7 @@ import type {
createItemSchema, createItemSchema,
createSetupSchema, createSetupSchema,
createThreadSchema, createThreadSchema,
reorderCandidatesSchema,
resolveThreadSchema, resolveThreadSchema,
syncSetupItemsSchema, syncSetupItemsSchema,
updateCandidateSchema, updateCandidateSchema,
@@ -33,6 +34,7 @@ export type UpdateThread = z.infer<typeof updateThreadSchema>;
export type CreateCandidate = z.infer<typeof createCandidateSchema>; export type CreateCandidate = z.infer<typeof createCandidateSchema>;
export type UpdateCandidate = z.infer<typeof updateCandidateSchema>; export type UpdateCandidate = z.infer<typeof updateCandidateSchema>;
export type ResolveThread = z.infer<typeof resolveThreadSchema>; export type ResolveThread = z.infer<typeof resolveThreadSchema>;
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
// Setup types // Setup types
export type CreateSetup = z.infer<typeof createSetupSchema>; export type CreateSetup = z.infer<typeof createSetupSchema>;

View File

@@ -57,6 +57,7 @@ export function createTestDb() {
status TEXT NOT NULL DEFAULT 'researching', status TEXT NOT NULL DEFAULT 'researching',
pros TEXT, pros TEXT,
cons TEXT, cons TEXT,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()), created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()) updated_at INTEGER NOT NULL DEFAULT (unixepoch())
) )

View File

@@ -6,6 +6,7 @@ import {
deleteThread, deleteThread,
getAllThreads, getAllThreads,
getThreadWithCandidates, getThreadWithCandidates,
reorderCandidates,
resolveThread, resolveThread,
updateCandidate, updateCandidate,
updateThread, updateThread,
@@ -362,6 +363,95 @@ describe("Thread Service", () => {
}); });
}); });
describe("sort_order ordering", () => {
it("getThreadWithCandidates returns candidates ordered by sort_order ascending", () => {
const thread = createThread(db, { name: "Order Test", categoryId: 1 });
const c1 = createCandidate(db, thread.id, {
name: "Candidate 1",
categoryId: 1,
});
const c2 = createCandidate(db, thread.id, {
name: "Candidate 2",
categoryId: 1,
});
const c3 = createCandidate(db, thread.id, {
name: "Candidate 3",
categoryId: 1,
});
// Manually set sort_orders out of creation order using reorderCandidates
reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]);
const result = getThreadWithCandidates(db, thread.id);
expect(result).toBeDefined();
expect(result?.candidates[0].id).toBe(c3.id);
expect(result?.candidates[1].id).toBe(c1.id);
expect(result?.candidates[2].id).toBe(c2.id);
});
it("createCandidate assigns sort_order = max existing sort_order + 1000", () => {
const thread = createThread(db, { name: "Append Test", categoryId: 1 });
// First candidate should get sort_order 1000
const c1 = createCandidate(db, thread.id, {
name: "First",
categoryId: 1,
});
expect(c1.sortOrder).toBe(1000);
// Second candidate should get sort_order 2000
const c2 = createCandidate(db, thread.id, {
name: "Second",
categoryId: 1,
});
expect(c2.sortOrder).toBe(2000);
});
});
describe("reorderCandidates", () => {
it("reorderCandidates updates sort_order so querying returns candidates in new order", () => {
const thread = createThread(db, { name: "Reorder Test", categoryId: 1 });
const c1 = createCandidate(db, thread.id, {
name: "Candidate 1",
categoryId: 1,
});
const c2 = createCandidate(db, thread.id, {
name: "Candidate 2",
categoryId: 1,
});
const c3 = createCandidate(db, thread.id, {
name: "Candidate 3",
categoryId: 1,
});
const result = reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]);
expect(result.success).toBe(true);
const fetched = getThreadWithCandidates(db, thread.id);
expect(fetched?.candidates[0].id).toBe(c3.id);
expect(fetched?.candidates[1].id).toBe(c1.id);
expect(fetched?.candidates[2].id).toBe(c2.id);
});
it("returns { success: false, error } when thread status is 'resolved'", () => {
const thread = createThread(db, { name: "Resolved Thread", categoryId: 1 });
const candidate = createCandidate(db, thread.id, {
name: "Winner",
categoryId: 1,
});
resolveThread(db, thread.id, candidate.id);
const result = reorderCandidates(db, thread.id, [candidate.id]);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it("returns { success: false } when thread does not exist", () => {
const result = reorderCandidates(db, 9999, [1, 2]);
expect(result.success).toBe(false);
});
});
describe("resolveThread", () => { describe("resolveThread", () => {
it("atomically creates collection item from candidate data and archives thread", () => { it("atomically creates collection item from candidate data and archives thread", () => {
const thread = createThread(db, { name: "Tent Decision", categoryId: 1 }); const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });