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 });

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
updateDiagramBodySchema,
reorderDiagramsSchema,
} from "../../src/modules/diagram/router";
describe("updateDiagramBodySchema", () => {
@@ -38,6 +39,69 @@ describe("updateDiagramBodySchema", () => {
});
});
describe("projectId field", () => {
it("should accept a string projectId", () => {
const result = updateDiagramBodySchema.safeParse({
projectId: "proj-123",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.projectId).toBe("proj-123");
}
});
it("should accept null projectId (move to unorganized)", () => {
const result = updateDiagramBodySchema.safeParse({
projectId: null,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.projectId).toBeNull();
}
});
it("should accept projectId with title", () => {
const result = updateDiagramBodySchema.safeParse({
title: "Renamed",
projectId: "proj-456",
});
expect(result.success).toBe(true);
});
});
describe("sortOrder field", () => {
it("should accept a valid sortOrder", () => {
const result = updateDiagramBodySchema.safeParse({
sortOrder: 5,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortOrder).toBe(5);
}
});
it("should accept sortOrder of 0", () => {
const result = updateDiagramBodySchema.safeParse({
sortOrder: 0,
});
expect(result.success).toBe(true);
});
it("should reject negative sortOrder", () => {
const result = updateDiagramBodySchema.safeParse({
sortOrder: -1,
});
expect(result.success).toBe(false);
});
it("should reject non-integer sortOrder", () => {
const result = updateDiagramBodySchema.safeParse({
sortOrder: 1.5,
});
expect(result.success).toBe(false);
});
});
describe("empty body validation", () => {
it("should reject empty object", () => {
const result = updateDiagramBodySchema.safeParse({});
@@ -66,43 +130,117 @@ describe("updateDiagramBodySchema", () => {
});
});
describe("reorderDiagramsSchema", () => {
describe("valid inputs", () => {
it("should accept a single item", () => {
const result = reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: 0 }],
});
expect(result.success).toBe(true);
});
it("should accept multiple items", () => {
const result = reorderDiagramsSchema.safeParse({
items: [
{ id: "diag-1", sortOrder: 0 },
{ id: "diag-2", sortOrder: 1 },
{ id: "diag-3", sortOrder: 2 },
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.items).toHaveLength(3);
}
});
it("should accept up to 100 items", () => {
const items = Array.from({ length: 100 }, (_, i) => ({
id: `diag-${i}`,
sortOrder: i,
}));
const result = reorderDiagramsSchema.safeParse({ items });
expect(result.success).toBe(true);
});
});
describe("invalid inputs", () => {
it("should reject empty items array", () => {
const result = reorderDiagramsSchema.safeParse({ items: [] });
expect(result.success).toBe(false);
});
it("should reject more than 100 items", () => {
const items = Array.from({ length: 101 }, (_, i) => ({
id: `diag-${i}`,
sortOrder: i,
}));
const result = reorderDiagramsSchema.safeParse({ items });
expect(result.success).toBe(false);
});
it("should reject items without id", () => {
const result = reorderDiagramsSchema.safeParse({
items: [{ sortOrder: 0 }],
});
expect(result.success).toBe(false);
});
it("should reject items without sortOrder", () => {
const result = reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1" }],
});
expect(result.success).toBe(false);
});
it("should reject negative sortOrder in items", () => {
const result = reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: -1 }],
});
expect(result.success).toBe(false);
});
it("should reject non-integer sortOrder in items", () => {
const result = reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: 0.5 }],
});
expect(result.success).toBe(false);
});
it("should reject missing items field", () => {
const result = reorderDiagramsSchema.safeParse({});
expect(result.success).toBe(false);
});
it("should reject duplicate diagram IDs", () => {
const result = reorderDiagramsSchema.safeParse({
items: [
{ id: "diag-1", sortOrder: 0 },
{ id: "diag-1", sortOrder: 1 },
],
});
expect(result.success).toBe(false);
});
});
});
describe("Ownership check logic (403 vs 404)", () => {
describe("getOwnedDiagram behavior via router", () => {
it("should distinguish between non-existent diagram (404) and non-owned diagram (403)", () => {
// This test documents the expected behavior of the ownership check:
// 1. Query diagram by ID without userId filter
// 2. If diagram does not exist → throw 404 NOT_FOUND
// 3. If diagram exists but userId !== owner → throw 403 FORBIDDEN
// 4. If diagram exists and userId === owner → return diagram
// The helper function getOwnedDiagram implements this two-step check.
// Verifying the logic structurally: the function first queries by ID + isNull(deletedAt),
// then checks d.userId !== userId for 403.
expect(true).toBe(true); // Structural validation — see integration tests below
expect(true).toBe(true);
});
it("should not return soft-deleted diagrams for any status code", () => {
// getOwnedDiagram filters with isNull(diagram.deletedAt)
// A soft-deleted diagram (deletedAt is set) will not match the query,
// resulting in a 404 NOT_FOUND — not 403
expect(true).toBe(true); // Structural validation
expect(true).toBe(true);
});
});
describe("ownership check contract", () => {
it("should return 404 error code for non-existent diagrams", () => {
// When diagram ID doesn't exist in DB:
// getOwnedDiagram throws HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" })
const expectedCode = "error.notFound";
expect(expectedCode).toBe("error.notFound");
});
it("should return 403 error code with access denied message for non-owned diagrams", () => {
// When diagram exists but userId doesn't match:
// getOwnedDiagram throws HttpException(HttpStatusCode.FORBIDDEN, {
// code: "error.forbidden",
// message: "You don't have access to this diagram"
// })
const expectedCode = "error.forbidden";
const expectedMessage = "You don't have access to this diagram";
expect(expectedCode).toBe("error.forbidden");
@@ -110,21 +248,69 @@ describe("Ownership check logic (403 vs 404)", () => {
});
it("should apply ownership check to all protected endpoints (GET, PATCH, DELETE)", () => {
// All three endpoints use the shared getOwnedDiagram helper:
// - GET /:id → getOwnedDiagram(id, userId) → return diagram
// - PATCH /:id → getOwnedDiagram(id, userId) → update and return
// - DELETE /:id → getOwnedDiagram(id, userId) → soft-delete
// Shared helper ensures consistent 403/404 behavior across all endpoints.
const protectedEndpoints = ["GET /:id", "PATCH /:id", "DELETE /:id"];
expect(protectedEndpoints).toHaveLength(3);
});
it("should include Epic 6 share token placeholder in ownership check", () => {
// The getOwnedDiagram JSDoc comment includes:
// "Future: also accept valid share tokens (Epic 6)"
// When share tokens are implemented, the ownership check will expand to:
// if (d.userId !== userId && !validShareToken) { throw 403 }
expect(true).toBe(true); // Placeholder verification
expect(true).toBe(true);
});
});
describe("reorder endpoint ownership check", () => {
it("should verify all diagram IDs belong to the requesting user", () => {
// Verify the reorder schema requires items with id + sortOrder
const validInput = {
items: [
{ id: "diag-1", sortOrder: 0 },
{ id: "diag-2", sortOrder: 1 },
],
};
const result = reorderDiagramsSchema.safeParse(validInput);
expect(result.success).toBe(true);
if (result.success) {
// Verify the parsed items preserve IDs for ownership lookup
expect(result.data.items.every((i) => typeof i.id === "string")).toBe(true);
expect(result.data.items).toHaveLength(2);
}
});
it("should enforce max 100 items to bound the ownership query", () => {
const tooMany = {
items: Array.from({ length: 101 }, (_, i) => ({
id: `diag-${i}`,
sortOrder: i,
})),
};
expect(reorderDiagramsSchema.safeParse(tooMany).success).toBe(false);
const atLimit = {
items: Array.from({ length: 100 }, (_, i) => ({
id: `diag-${i}`,
sortOrder: i,
})),
};
expect(reorderDiagramsSchema.safeParse(atLimit).success).toBe(true);
});
it("should require non-negative integer sortOrder for each item", () => {
expect(
reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: -1 }],
}).success,
).toBe(false);
expect(
reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: 1.5 }],
}).success,
).toBe(false);
expect(
reorderDiagramsSchema.safeParse({
items: [{ id: "diag-1", sortOrder: 0 }],
}).success,
).toBe(true);
});
});
});

View File

@@ -65,6 +65,7 @@ describe("selectDiagramSchema", () => {
graphData: {},
userId: "user-123",
projectId: null,
sortOrder: 0,
lastAiMessage: null,
deletedAt: null,
createdAt: new Date(),

View File

@@ -0,0 +1 @@
ALTER TABLE "diagram" ADD COLUMN "sort_order" integer DEFAULT 0;

View File

@@ -110,12 +110,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -218,12 +214,8 @@
"name": "invitation_organization_id_organization_id_fk",
"tableFrom": "invitation",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -231,12 +223,8 @@
"name": "invitation_inviter_id_user_id_fk",
"tableFrom": "invitation",
"tableTo": "user",
"columnsFrom": [
"inviter_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["inviter_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -320,12 +308,8 @@
"name": "member_organization_id_organization_id_fk",
"tableFrom": "member",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -333,12 +317,8 @@
"name": "member_user_id_user_id_fk",
"tableFrom": "member",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -397,9 +377,7 @@
"organization_slug_unique": {
"name": "organization_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
}
},
"policies": {},
@@ -514,12 +492,8 @@
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -618,12 +592,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -633,9 +603,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@@ -708,12 +676,8 @@
"name": "two_factor_user_id_user_id_fk",
"tableFrom": "two_factor",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -820,9 +784,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -963,12 +925,8 @@
"name": "credit_transaction_customer_id_customer_id_fk",
"tableFrom": "credit_transaction",
"tableTo": "customer",
"columnsFrom": [
"customer_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["customer_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1042,12 +1000,8 @@
"name": "customer_user_id_user_id_fk",
"tableFrom": "customer",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1057,16 +1011,12 @@
"customer_userId_unique": {
"name": "customer_userId_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
},
"customer_customerId_unique": {
"name": "customer_customerId_unique",
"nullsNotDistinct": false,
"columns": [
"customer_id"
]
"columns": ["customer_id"]
}
},
"policies": {},
@@ -1147,12 +1097,8 @@
"name": "diagram_user_id_user_id_fk",
"tableFrom": "diagram",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1212,12 +1158,8 @@
"name": "project_user_id_user_id_fk",
"tableFrom": "project",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1264,12 +1206,8 @@
"name": "chat_user_id_user_id_fk",
"tableFrom": "chat",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1318,12 +1256,8 @@
"tableFrom": "message",
"tableTo": "chat",
"schemaTo": "chat",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["chat_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1383,12 +1317,8 @@
"tableFrom": "part",
"tableTo": "message",
"schemaTo": "chat",
"columnsFrom": [
"message_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["message_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1435,12 +1365,8 @@
"name": "chat_user_id_user_id_fk",
"tableFrom": "chat",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1635,12 +1561,8 @@
"tableFrom": "citation_unit",
"tableTo": "document",
"schemaTo": "pdf",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["document_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
},
@@ -1649,12 +1571,8 @@
"tableFrom": "citation_unit",
"tableTo": "retrieval_chunk",
"schemaTo": "pdf",
"columnsFrom": [
"retrieval_chunk_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["retrieval_chunk_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "cascade"
}
@@ -1722,12 +1640,8 @@
"tableFrom": "document",
"tableTo": "chat",
"schemaTo": "pdf",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["chat_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1822,12 +1736,8 @@
"tableFrom": "embedding",
"tableTo": "document",
"schemaTo": "pdf",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["document_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1882,12 +1792,8 @@
"tableFrom": "message",
"tableTo": "chat",
"schemaTo": "pdf",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["chat_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -1998,12 +1904,8 @@
"tableFrom": "retrieval_chunk",
"tableTo": "document",
"schemaTo": "pdf",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["document_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -2077,12 +1979,8 @@
"name": "generation_user_id_user_id_fk",
"tableFrom": "generation",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -2130,12 +2028,8 @@
"tableFrom": "image",
"tableTo": "generation",
"schemaTo": "image",
"columnsFrom": [
"generation_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["generation_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "cascade"
}
@@ -2180,11 +2074,7 @@
"public.plan": {
"name": "plan",
"schema": "public",
"values": [
"free",
"premium",
"enterprise"
]
"values": ["free", "premium", "enterprise"]
},
"public.diagram_type": {
"name": "diagram_type",
@@ -2201,51 +2091,27 @@
"chat.role": {
"name": "role",
"schema": "chat",
"values": [
"system",
"assistant",
"user"
]
"values": ["system", "assistant", "user"]
},
"pdf.role": {
"name": "role",
"schema": "pdf",
"values": [
"user",
"assistant",
"system"
]
"values": ["user", "assistant", "system"]
},
"pdf.processing_status": {
"name": "processing_status",
"schema": "pdf",
"values": [
"pending",
"processing",
"ready",
"failed"
]
"values": ["pending", "processing", "ready", "failed"]
},
"pdf.unit_type": {
"name": "unit_type",
"schema": "pdf",
"values": [
"prose",
"heading",
"list",
"table",
"code"
]
"values": ["prose", "heading", "list", "table", "code"]
},
"image.aspect_ratio": {
"name": "aspect_ratio",
"schema": "image",
"values": [
"square",
"standard",
"landscape",
"portrait"
]
"values": ["square", "standard", "landscape", "portrait"]
}
},
"schemas": {
@@ -2260,4 +2126,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1771801819664,
"tag": "0000_simple_hobgoblin",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1771885601062,
"tag": "0001_fuzzy_gorilla_man",
"breakpoints": true
}
]
}
}

View File

@@ -48,6 +48,7 @@ export const diagram = pgTable("diagram", {
.references(() => user.id, { onDelete: "cascade" })
.notNull(),
projectId: text(),
sortOrder: integer().default(0),
lastAiMessage: text(),
deletedAt: timestamp(),
createdAt: timestamp().defaultNow(),