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:
1
drizzle/0005_clear_micromax.sql
Normal file
1
drizzle/0005_clear_micromax.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `thread_candidates` ADD `sort_order` real DEFAULT 0 NOT NULL;
|
||||||
505
drizzle/meta/0005_snapshot.json
Normal file
505
drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user