From f01d71d6b4d7ed06e42345b74c6f49b4b4c7fb84 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 22:21:42 +0100 Subject: [PATCH] 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 --- drizzle/0005_clear_micromax.sql | 1 + drizzle/meta/0005_snapshot.json | 505 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 1 + src/server/services/thread.service.ts | 47 ++- src/shared/schemas.ts | 4 + src/shared/types.ts | 2 + tests/helpers/db.ts | 1 + tests/services/thread.service.test.ts | 90 +++++ 9 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 drizzle/0005_clear_micromax.sql create mode 100644 drizzle/meta/0005_snapshot.json diff --git a/drizzle/0005_clear_micromax.sql b/drizzle/0005_clear_micromax.sql new file mode 100644 index 0000000..1c107c2 --- /dev/null +++ b/drizzle/0005_clear_micromax.sql @@ -0,0 +1 @@ +ALTER TABLE `thread_candidates` ADD `sort_order` real DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..063d532 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 27ac604..714ce52 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1773693113029, "tag": "0004_soft_synch", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1773696058992, + "tag": "0005_clear_micromax", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index f8f43da..48787b7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -61,6 +61,7 @@ export const threadCandidates = sqliteTable("thread_candidates", { status: text("status").notNull().default("researching"), pros: text("pros"), cons: text("cons"), + sortOrder: real("sort_order").notNull().default(0), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()), diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index 0eafc21..aa3ad1e 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -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, diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index ddbfdb8..22451c8 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -63,6 +63,10 @@ export const resolveThreadSchema = z.object({ candidateId: z.number().int().positive(), }); +export const reorderCandidatesSchema = z.object({ + orderedIds: z.array(z.number().int().positive()).min(1), +}); + // Setup schemas export const createSetupSchema = z.object({ name: z.string().min(1, "Setup name is required"), diff --git a/src/shared/types.ts b/src/shared/types.ts index 23ea5bd..f96624e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,7 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, + reorderCandidatesSchema, resolveThreadSchema, syncSetupItemsSchema, updateCandidateSchema, @@ -33,6 +34,7 @@ export type UpdateThread = z.infer; export type CreateCandidate = z.infer; export type UpdateCandidate = z.infer; export type ResolveThread = z.infer; +export type ReorderCandidates = z.infer; // Setup types export type CreateSetup = z.infer; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 51e9c19..4e47ce6 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -57,6 +57,7 @@ export function createTestDb() { status TEXT NOT NULL DEFAULT 'researching', pros TEXT, cons TEXT, + sort_order REAL NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) diff --git a/tests/services/thread.service.test.ts b/tests/services/thread.service.test.ts index 303a5e8..d9f0c5b 100644 --- a/tests/services/thread.service.test.ts +++ b/tests/services/thread.service.test.ts @@ -6,6 +6,7 @@ import { deleteThread, getAllThreads, getThreadWithCandidates, + reorderCandidates, resolveThread, updateCandidate, 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", () => { it("atomically creates collection item from candidate data and archives thread", () => { const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });