feat: implement Story 1.4 — recent view and drag-and-drop organization

Add sortOrder column to diagrams, extend PATCH endpoint with projectId
and sortOrder fields, add POST /diagrams/reorder bulk endpoint with
ownership verification and duplicate ID validation. Enhance RecentList
with lastAiMessage preview subtitle. Implement @dnd-kit drag-and-drop
in ProjectTree sidebar with cross-project moves, intra-project reorder,
optimistic updates, drag overlay, drop indicators, and keyboard/pointer
sensor support. Context-aware GET ordering: sortOrder for project views,
updatedAt for recent/all views. 141 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 23:21:09 +00:00
parent e9cd685d3d
commit 098f4968be
14 changed files with 3366 additions and 363 deletions

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { and, desc, eq, isNull } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull } from "drizzle-orm";
import { z } from "zod";
import { diagram } from "@turbostarter/db/schema";
@@ -30,10 +30,33 @@ export const createDiagramSchema = z.object({
export const updateDiagramBodySchema = z
.object({
title: z.string().min(1).max(255).optional(),
projectId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
.refine((data) => data.title !== undefined, {
message: "At least one field must be provided",
});
.refine(
(data) =>
data.title !== undefined ||
data.projectId !== undefined ||
data.sortOrder !== undefined,
{ message: "At least one field must be provided" },
);
export const reorderDiagramsSchema = z
.object({
items: z
.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
}),
)
.min(1)
.max(100),
})
.refine(
(data) => new Set(data.items.map((i) => i.id)).size === data.items.length,
{ message: "Duplicate diagram IDs not allowed" },
);
/**
* Fetch a diagram by ID and verify ownership.
@@ -82,14 +105,56 @@ export const diagramRouter = new Hono()
conditions.push(isNull(diagram.projectId));
}
const orderClauses = projectId
? [asc(diagram.sortOrder), desc(diagram.updatedAt)]
: [desc(diagram.updatedAt)];
const diagrams = await db
.select()
.from(diagram)
.where(and(...conditions))
.orderBy(desc(diagram.updatedAt));
.orderBy(...orderClauses);
return c.json({ data: diagrams });
})
.post(
"/reorder",
enforceAuth,
validate("json", reorderDiagramsSchema),
async (c) => {
const { items } = c.req.valid("json");
const diagramIds = items.map((i) => i.id);
const owned = await db
.select({ id: diagram.id })
.from(diagram)
.where(
and(
inArray(diagram.id, diagramIds),
eq(diagram.userId, c.var.user.id),
isNull(diagram.deletedAt),
),
);
if (owned.length !== diagramIds.length) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
message: "One or more diagrams not found or not owned",
});
}
await db.transaction(async (tx) => {
for (const item of items) {
await tx
.update(diagram)
.set({ sortOrder: item.sortOrder })
.where(eq(diagram.id, item.id));
}
});
return c.json({ data: { success: true } });
},
)
.get("/:id", enforceAuth, async (c) => {
const d = await getOwnedDiagram(c.req.param("id"), c.var.user.id);
return c.json({ data: d });