feat: implement Story 1.2 — organize diagrams into projects

Add project CRUD API (GET/POST/PATCH/DELETE) with ownership checks and
transactional delete. Add diagram list filtering by projectId and
unorganized query params with typed Zod query schema for Hono RPC
type safety. Create DiagramSidebar with Projects tree (expand/collapse,
inline rename) and Recent tab. Add project picker to CreateDiagramDialog.
Includes 15 schema validation tests (107 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 21:45:16 +00:00
parent 392da385f4
commit 85e06c25c7
15 changed files with 1116 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ import { aiRouter } from "./modules/ai/router";
import { authRouter } from "./modules/auth/router";
import { billingRouter } from "./modules/billing/router";
import { diagramRouter } from "./modules/diagram/router";
import { projectRouter } from "./modules/diagram/project-router";
import { organizationRouter } from "./modules/organization/router";
import { storageRouter } from "./modules/storage/router";
import { onError } from "./utils/on-error";
@@ -50,6 +51,7 @@ const appRouter = new Hono()
.route("/auth", authRouter)
.route("/billing", billingRouter)
.route("/diagrams", diagramRouter)
.route("/projects", projectRouter)
.route("/organizations", organizationRouter)
.route("/storage", storageRouter)
.onError(onError);

View File

@@ -0,0 +1,111 @@
import { Hono } from "hono";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { diagram, project } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { HttpException } from "@turbostarter/shared/utils";
import { enforceAuth, validate } from "../../middleware";
export const createProjectSchema = z.object({
name: z.string().min(1).max(100),
});
export const updateProjectSchema = z
.object({
name: z.string().min(1).max(100).optional(),
sortOrder: z.number().int().optional(),
})
.refine((data) => data.name !== undefined || data.sortOrder !== undefined, {
message: "At least one field (name or sortOrder) must be provided",
});
export const projectRouter = new Hono()
.get("/", enforceAuth, async (c) => {
const projects = await db
.select()
.from(project)
.where(eq(project.userId, c.var.user.id))
.orderBy(asc(project.sortOrder));
return c.json({ data: projects });
})
.post(
"/",
enforceAuth,
validate("json", createProjectSchema),
async (c) => {
const input = c.req.valid("json");
const [created] = await db
.insert(project)
.values({
...input,
userId: c.var.user.id,
})
.returning();
return c.json({ data: created });
},
)
.patch(
"/:id",
enforceAuth,
validate("json", updateProjectSchema),
async (c) => {
const [existing] = await db
.select()
.from(project)
.where(
and(
eq(project.id, c.req.param("id")),
eq(project.userId, c.var.user.id),
),
);
if (!existing) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "error.notFound",
});
}
const [updated] = await db
.update(project)
.set(c.req.valid("json"))
.where(eq(project.id, c.req.param("id")))
.returning();
return c.json({ data: updated });
},
)
.delete("/:id", enforceAuth, async (c) => {
const [existing] = await db
.select()
.from(project)
.where(
and(
eq(project.id, c.req.param("id")),
eq(project.userId, c.var.user.id),
),
);
if (!existing) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "error.notFound",
});
}
await db.transaction(async (tx) => {
// Move diagrams to Unorganized (null projectId)
await tx
.update(diagram)
.set({ projectId: null })
.where(eq(diagram.projectId, c.req.param("id")));
// Delete the project
await tx.delete(project).where(eq(project.id, c.req.param("id")));
});
return c.json({ data: { success: true } });
});

View File

@@ -9,6 +9,11 @@ import { HttpException } from "@turbostarter/shared/utils";
import { enforceAuth, validate } from "../../middleware";
export const listDiagramsQuerySchema = z.object({
projectId: z.string().optional(),
unorganized: z.enum(["true"]).optional(),
});
export const createDiagramSchema = z.object({
title: z.string().min(1).max(255),
type: z.enum([
@@ -23,13 +28,24 @@ export const createDiagramSchema = z.object({
});
export const diagramRouter = new Hono()
.get("/", enforceAuth, async (c) => {
.get("/", enforceAuth, validate("query", listDiagramsQuerySchema), async (c) => {
const { projectId, unorganized } = c.req.valid("query");
const conditions = [
eq(diagram.userId, c.var.user.id),
isNull(diagram.deletedAt),
];
if (projectId) {
conditions.push(eq(diagram.projectId, projectId));
} else if (unorganized === "true") {
conditions.push(isNull(diagram.projectId));
}
const diagrams = await db
.select()
.from(diagram)
.where(
and(eq(diagram.userId, c.var.user.id), isNull(diagram.deletedAt)),
)
.where(and(...conditions))
.orderBy(desc(diagram.updatedAt));
return c.json({ data: diagrams });

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import {
createProjectSchema,
updateProjectSchema,
} from "../../src/modules/diagram/project-router";
describe("createProjectSchema", () => {
it("should accept a valid name", () => {
const result = createProjectSchema.safeParse({ name: "My Project" });
expect(result.success).toBe(true);
});
it("should reject empty name", () => {
const result = createProjectSchema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("should reject name over 100 characters", () => {
const result = createProjectSchema.safeParse({ name: "a".repeat(101) });
expect(result.success).toBe(false);
});
it("should accept name at max length (100)", () => {
const result = createProjectSchema.safeParse({ name: "a".repeat(100) });
expect(result.success).toBe(true);
});
it("should reject missing name", () => {
const result = createProjectSchema.safeParse({});
expect(result.success).toBe(false);
});
it("should strip unknown fields", () => {
const result = createProjectSchema.safeParse({
name: "Test",
unknownField: "should be stripped",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toHaveProperty("unknownField");
}
});
});
describe("updateProjectSchema", () => {
it("should accept valid name update", () => {
const result = updateProjectSchema.safeParse({ name: "Updated Name" });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe("Updated Name");
}
});
it("should accept valid sortOrder update", () => {
const result = updateProjectSchema.safeParse({ sortOrder: 5 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortOrder).toBe(5);
}
});
it("should accept both name and sortOrder", () => {
const result = updateProjectSchema.safeParse({
name: "New Name",
sortOrder: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({ name: "New Name", sortOrder: 3 });
}
});
it("should reject empty object (at least one field required)", () => {
const result = updateProjectSchema.safeParse({});
expect(result.success).toBe(false);
});
it("should reject name over 100 characters", () => {
const result = updateProjectSchema.safeParse({ name: "a".repeat(101) });
expect(result.success).toBe(false);
});
it("should reject empty name", () => {
const result = updateProjectSchema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("should reject non-integer sortOrder", () => {
const result = updateProjectSchema.safeParse({ sortOrder: 1.5 });
expect(result.success).toBe(false);
});
it("should reject non-number sortOrder", () => {
const result = updateProjectSchema.safeParse({ sortOrder: "abc" });
expect(result.success).toBe(false);
});
it("should strip unknown fields", () => {
const result = updateProjectSchema.safeParse({
name: "Test",
unknownField: "should be stripped",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toHaveProperty("unknownField");
}
});
});

View File

@@ -153,6 +153,9 @@ import {
Server,
ArrowRightLeft,
GitBranch,
FolderOpen,
Inbox,
Trash2,
} from "lucide-react";
import { Icons as GlobalIcons } from "@turbostarter/ui/assets";
@@ -381,6 +384,9 @@ export const Icons = {
Server,
ArrowRightLeft,
GitBranch,
FolderOpen,
Inbox,
Trash2,
MinusIcon: Minus,
PlusIcon: Plus,
// AI provider icons