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:
@@ -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);
|
||||
|
||||
111
packages/api/src/modules/diagram/project-router.ts
Normal file
111
packages/api/src/modules/diagram/project-router.ts
Normal 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 } });
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user