286 lines
13 KiB
Markdown
286 lines
13 KiB
Markdown
---
|
|
phase: 11-candidate-ranking
|
|
plan: "01"
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/db/schema.ts
|
|
- tests/helpers/db.ts
|
|
- src/server/services/thread.service.ts
|
|
- src/server/routes/threads.ts
|
|
- src/shared/schemas.ts
|
|
- src/shared/types.ts
|
|
- tests/services/thread.service.test.ts
|
|
- tests/routes/threads.test.ts
|
|
autonomous: true
|
|
requirements: [RANK-01, RANK-04, RANK-05]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Candidates returned from getThreadWithCandidates are ordered by sort_order ascending"
|
|
- "Calling reorderCandidates with a new ID sequence updates sort_order values to match that sequence"
|
|
- "PATCH /api/threads/:id/candidates/reorder returns 200 and persists new order"
|
|
- "reorderCandidates returns error when thread status is not active"
|
|
- "New candidates created via createCandidate are appended to end of rank (highest sort_order + 1000)"
|
|
artifacts:
|
|
- path: "src/db/schema.ts"
|
|
provides: "sortOrder REAL column on threadCandidates"
|
|
contains: "sortOrder"
|
|
- path: "src/shared/schemas.ts"
|
|
provides: "reorderCandidatesSchema Zod validator"
|
|
contains: "reorderCandidatesSchema"
|
|
- path: "src/shared/types.ts"
|
|
provides: "ReorderCandidates type"
|
|
contains: "ReorderCandidates"
|
|
- path: "src/server/services/thread.service.ts"
|
|
provides: "reorderCandidates function + ORDER BY sort_order + createCandidate sort_order appending"
|
|
exports: ["reorderCandidates"]
|
|
- path: "src/server/routes/threads.ts"
|
|
provides: "PATCH /:id/candidates/reorder endpoint"
|
|
contains: "candidates/reorder"
|
|
- path: "tests/helpers/db.ts"
|
|
provides: "sort_order column in CREATE TABLE thread_candidates"
|
|
contains: "sort_order"
|
|
key_links:
|
|
- from: "src/server/routes/threads.ts"
|
|
to: "src/server/services/thread.service.ts"
|
|
via: "reorderCandidates(db, threadId, orderedIds)"
|
|
pattern: "reorderCandidates"
|
|
- from: "src/server/routes/threads.ts"
|
|
to: "src/shared/schemas.ts"
|
|
via: "zValidator with reorderCandidatesSchema"
|
|
pattern: "reorderCandidatesSchema"
|
|
- from: "src/server/services/thread.service.ts"
|
|
to: "src/db/schema.ts"
|
|
via: "threadCandidates.sortOrder in ORDER BY and UPDATE"
|
|
pattern: "threadCandidates\\.sortOrder"
|
|
---
|
|
|
|
<objective>
|
|
Add sort_order column to thread_candidates, implement reorder service and API endpoint, and update candidate ordering throughout the backend.
|
|
|
|
Purpose: Provides the persistence layer for drag-to-reorder ranking (RANK-01, RANK-04) and enforces the resolved-thread guard (RANK-05). The frontend plan (11-02) depends on this.
|
|
Output: Working PATCH /api/threads/:id/candidates/reorder endpoint, sort_order-based ordering in getThreadWithCandidates, sort_order appending in createCandidate, full test coverage.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/11-candidate-ranking/11-CONTEXT.md
|
|
@.planning/phases/11-candidate-ranking/11-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|
|
|
From src/db/schema.ts (threadCandidates table — add sortOrder here):
|
|
```typescript
|
|
export const threadCandidates = sqliteTable("thread_candidates", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
threadId: integer("thread_id").notNull().references(() => threads.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
weightGrams: real("weight_grams"),
|
|
priceCents: integer("price_cents"),
|
|
categoryId: integer("category_id").notNull().references(() => categories.id),
|
|
notes: text("notes"),
|
|
productUrl: text("product_url"),
|
|
imageFilename: text("image_filename"),
|
|
status: text("status").notNull().default("researching"),
|
|
pros: text("pros"),
|
|
cons: text("cons"),
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
```
|
|
|
|
From src/server/services/thread.service.ts (key functions to modify):
|
|
```typescript
|
|
type Db = typeof prodDb;
|
|
export function getThreadWithCandidates(db: Db, threadId: number) // add .orderBy(threadCandidates.sortOrder)
|
|
export function createCandidate(db: Db, threadId: number, data: ...) // add sort_order = max + 1000
|
|
export function resolveThread(db: Db, threadId: number, candidateId: number) // existing status check pattern to reuse
|
|
```
|
|
|
|
From src/shared/schemas.ts (existing patterns):
|
|
```typescript
|
|
export const createCandidateSchema = z.object({ ... });
|
|
export const resolveThreadSchema = z.object({ candidateId: z.number().int().positive() });
|
|
```
|
|
|
|
From src/shared/types.ts (add new type):
|
|
```typescript
|
|
export type ResolveThread = z.infer<typeof resolveThreadSchema>;
|
|
// Add: export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
|
|
```
|
|
|
|
From src/server/routes/threads.ts (route pattern):
|
|
```typescript
|
|
type Env = { Variables: { db?: any } };
|
|
const app = new Hono<Env>();
|
|
// Pattern: app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { ... });
|
|
```
|
|
|
|
From src/client/lib/api.ts:
|
|
```typescript
|
|
export async function apiPatch<T>(url: string, body: unknown): Promise<T>;
|
|
```
|
|
|
|
From tests/helpers/db.ts (thread_candidates CREATE TABLE — add sort_order):
|
|
```sql
|
|
CREATE TABLE thread_candidates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
weight_grams REAL,
|
|
price_cents INTEGER,
|
|
category_id INTEGER NOT NULL REFERENCES categories(id),
|
|
notes TEXT,
|
|
product_url TEXT,
|
|
image_filename TEXT,
|
|
status TEXT NOT NULL DEFAULT 'researching',
|
|
pros TEXT,
|
|
cons TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
)
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Schema, migration, service layer, and tests for sort_order + reorder</name>
|
|
<files>src/db/schema.ts, tests/helpers/db.ts, src/server/services/thread.service.ts, src/shared/schemas.ts, src/shared/types.ts, tests/services/thread.service.test.ts</files>
|
|
<behavior>
|
|
- Test: getThreadWithCandidates returns candidates ordered by sort_order ascending (create 3 candidates with different sort_orders, verify order)
|
|
- Test: reorderCandidates(db, threadId, [id3, id1, id2]) updates sort_order so querying returns [id3, id1, id2]
|
|
- Test: reorderCandidates returns { success: false, error } when thread status is "resolved"
|
|
- Test: createCandidate assigns sort_order = max existing sort_order + 1000 (first candidate gets 1000, second gets 2000)
|
|
- Test: reorderCandidates returns { success: false } when thread does not exist
|
|
</behavior>
|
|
<action>
|
|
1. **Schema** (`src/db/schema.ts`): Add `sortOrder: real("sort_order").notNull().default(0)` to the `threadCandidates` table definition.
|
|
|
|
2. **Migration**: Run `bun run db:generate` to produce the Drizzle migration SQL. Then apply it with `bun run db:push`. After applying, run a data backfill to space existing candidates:
|
|
```sql
|
|
UPDATE thread_candidates SET sort_order = (
|
|
SELECT (ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at)) * 1000
|
|
FROM thread_candidates AS tc2 WHERE tc2.id = thread_candidates.id
|
|
);
|
|
```
|
|
Execute this backfill via the Drizzle migration custom SQL or a small script.
|
|
|
|
3. **Test helper** (`tests/helpers/db.ts`): Add `sort_order REAL NOT NULL DEFAULT 0` to the CREATE TABLE thread_candidates statement (after the `cons TEXT` line, before `created_at`).
|
|
|
|
4. **Zod schema** (`src/shared/schemas.ts`): Add:
|
|
```typescript
|
|
export const reorderCandidatesSchema = z.object({
|
|
orderedIds: z.array(z.number().int().positive()).min(1),
|
|
});
|
|
```
|
|
|
|
5. **Types** (`src/shared/types.ts`): Add import of `reorderCandidatesSchema` and:
|
|
```typescript
|
|
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
|
|
```
|
|
|
|
6. **Service** (`src/server/services/thread.service.ts`):
|
|
- In `getThreadWithCandidates`: Add `.orderBy(threadCandidates.sortOrder)` to the candidateList query (after `.where()`).
|
|
- In `createCandidate`: Before inserting, query `MAX(sort_order)` from threadCandidates where threadId matches. Set `sortOrder: (maxRow?.maxOrder ?? 0) + 1000` in the `.values()` call. Use `sql<number>` template for the MAX query.
|
|
- Add new exported function `reorderCandidates(db, threadId, orderedIds)`:
|
|
- Wrap in `db.transaction()`.
|
|
- Verify thread exists and `status === "active"` (return `{ success: false, error: "Thread not active" }` if not).
|
|
- Loop through `orderedIds`, UPDATE each candidate's `sortOrder` to `(index + 1) * 1000`.
|
|
- Return `{ success: true }`.
|
|
|
|
7. **Tests** (`tests/services/thread.service.test.ts`):
|
|
- Import `reorderCandidates` from the service.
|
|
- Add a new `describe("reorderCandidates", () => { ... })` block with the behavior tests listed above.
|
|
- Add test for `getThreadWithCandidates` ordering by sort_order (create candidates, set different sort_orders manually via db, verify order).
|
|
- Add test for `createCandidate` sort_order appending.
|
|
</action>
|
|
<verify>
|
|
<automated>bun test tests/services/thread.service.test.ts</automated>
|
|
</verify>
|
|
<done>All existing thread service tests pass (28+) plus 5+ new tests for reorderCandidates, sort_order ordering, sort_order appending. sortOrder column exists in schema with REAL type.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: PATCH reorder route + route tests</name>
|
|
<files>src/server/routes/threads.ts, tests/routes/threads.test.ts</files>
|
|
<behavior>
|
|
- Test: PATCH /api/threads/:id/candidates/reorder with valid orderedIds returns 200 + { success: true }
|
|
- Test: After PATCH reorder, GET /api/threads/:id returns candidates in the new order
|
|
- Test: PATCH /api/threads/:id/candidates/reorder on a resolved thread returns 400
|
|
- Test: PATCH /api/threads/:id/candidates/reorder with empty body returns 400 (Zod validation)
|
|
</behavior>
|
|
<action>
|
|
1. **Route** (`src/server/routes/threads.ts`):
|
|
- Import `reorderCandidatesSchema` from `../../shared/schemas.ts`.
|
|
- Import `reorderCandidates` from `../services/thread.service.ts`.
|
|
- Add PATCH route BEFORE the resolution route (to avoid param conflicts):
|
|
```typescript
|
|
app.patch(
|
|
"/:id/candidates/reorder",
|
|
zValidator("json", reorderCandidatesSchema),
|
|
(c) => {
|
|
const db = c.get("db");
|
|
const threadId = Number(c.req.param("id"));
|
|
const { orderedIds } = c.req.valid("json");
|
|
const result = reorderCandidates(db, threadId, orderedIds);
|
|
if (!result.success) return c.json({ error: result.error }, 400);
|
|
return c.json({ success: true });
|
|
},
|
|
);
|
|
```
|
|
|
|
2. **Route tests** (`tests/routes/threads.test.ts`):
|
|
- Add a new `describe("PATCH /api/threads/:id/candidates/reorder", () => { ... })` block.
|
|
- Test: Create a thread with 3 candidates via API, PATCH reorder with reversed IDs, GET thread and verify candidates array is in the new order.
|
|
- Test: Resolve a thread, then PATCH reorder returns 400.
|
|
- Test: PATCH with invalid body (empty orderedIds array or missing field) returns 400.
|
|
</action>
|
|
<verify>
|
|
<automated>bun test tests/routes/threads.test.ts</automated>
|
|
</verify>
|
|
<done>PATCH /api/threads/:id/candidates/reorder returns 200 on active thread + persists order. Returns 400 on resolved thread. All existing route tests still pass.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
# Full test suite — all existing + new tests green
|
|
bun test
|
|
|
|
# Verify sort_order column exists in schema
|
|
grep -n "sortOrder" src/db/schema.ts
|
|
|
|
# Verify reorder endpoint registered
|
|
grep -n "candidates/reorder" src/server/routes/threads.ts
|
|
|
|
# Verify test helper updated
|
|
grep -n "sort_order" tests/helpers/db.ts
|
|
```
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- sort_order REAL column added to threadCandidates schema and test helper
|
|
- getThreadWithCandidates returns candidates sorted by sort_order ascending
|
|
- createCandidate appends new candidates at max sort_order + 1000
|
|
- reorderCandidates service function updates sort_order in transaction, rejects resolved threads
|
|
- PATCH /api/threads/:id/candidates/reorder validated with Zod, returns 200/400 correctly
|
|
- All existing tests pass with zero regressions + 8+ new tests
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/11-candidate-ranking/11-01-SUMMARY.md`
|
|
</output>
|