feat: implement Story 1.1 — create and view diagrams
Add diagram/project DB schema, CRUD API, dashboard pages with grid/card/ empty state, create dialog with type selector, editor placeholder, and 26 schema validation tests. Includes code review fixes: soft-delete filter on GET /:id, error handling, keyboard accessibility, type-safe API response types, and error states on pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { adminRouter } from "./modules/admin/router";
|
||||
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 { organizationRouter } from "./modules/organization/router";
|
||||
import { storageRouter } from "./modules/storage/router";
|
||||
import { onError } from "./utils/on-error";
|
||||
@@ -48,6 +49,7 @@ const appRouter = new Hono()
|
||||
.route("/ai", aiRouter)
|
||||
.route("/auth", authRouter)
|
||||
.route("/billing", billingRouter)
|
||||
.route("/diagrams", diagramRouter)
|
||||
.route("/organizations", organizationRouter)
|
||||
.route("/storage", storageRouter)
|
||||
.onError(onError);
|
||||
|
||||
73
packages/api/src/modules/diagram/router.ts
Normal file
73
packages/api/src/modules/diagram/router.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Hono } from "hono";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { diagram } 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 createDiagramSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
type: z.enum([
|
||||
"bpmn",
|
||||
"er",
|
||||
"orgchart",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"flowchart",
|
||||
]),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const diagramRouter = new Hono()
|
||||
.get("/", enforceAuth, async (c) => {
|
||||
const diagrams = await db
|
||||
.select()
|
||||
.from(diagram)
|
||||
.where(
|
||||
and(eq(diagram.userId, c.var.user.id), isNull(diagram.deletedAt)),
|
||||
)
|
||||
.orderBy(desc(diagram.updatedAt));
|
||||
|
||||
return c.json({ data: diagrams });
|
||||
})
|
||||
.get("/:id", enforceAuth, async (c) => {
|
||||
const [d] = await db
|
||||
.select()
|
||||
.from(diagram)
|
||||
.where(
|
||||
and(
|
||||
eq(diagram.id, c.req.param("id")),
|
||||
eq(diagram.userId, c.var.user.id),
|
||||
isNull(diagram.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!d) {
|
||||
throw new HttpException(HttpStatusCode.NOT_FOUND, {
|
||||
code: "error.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ data: d });
|
||||
})
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
validate("json", createDiagramSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const [created] = await db
|
||||
.insert(diagram)
|
||||
.values({
|
||||
...input,
|
||||
userId: c.var.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ data: created });
|
||||
},
|
||||
);
|
||||
112
packages/api/tests/diagram/diagram-db-schema.test.ts
Normal file
112
packages/api/tests/diagram/diagram-db-schema.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
insertDiagramSchema,
|
||||
selectDiagramSchema,
|
||||
insertProjectSchema,
|
||||
selectProjectSchema,
|
||||
} from "@turbostarter/db/schema";
|
||||
|
||||
describe("insertDiagramSchema", () => {
|
||||
it("should accept valid diagram insert data", () => {
|
||||
const result = insertDiagramSchema.safeParse({
|
||||
title: "Test Diagram",
|
||||
type: "flowchart",
|
||||
userId: "user-123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject missing required fields", () => {
|
||||
const result = insertDiagramSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid diagram type", () => {
|
||||
const result = insertDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "invalid-type",
|
||||
userId: "user-123",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept all valid diagram types", () => {
|
||||
const types = ["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"];
|
||||
for (const type of types) {
|
||||
const result = insertDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type,
|
||||
userId: "user-123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept optional fields", () => {
|
||||
const result = insertDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "bpmn",
|
||||
userId: "user-123",
|
||||
projectId: "proj-123",
|
||||
lastAiMessage: "Hello",
|
||||
graphData: { nodes: [] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectDiagramSchema", () => {
|
||||
it("should accept a complete diagram record", () => {
|
||||
const result = selectDiagramSchema.safeParse({
|
||||
id: "diag-123",
|
||||
title: "Test Diagram",
|
||||
type: "er",
|
||||
graphData: {},
|
||||
userId: "user-123",
|
||||
projectId: null,
|
||||
lastAiMessage: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertProjectSchema", () => {
|
||||
it("should accept valid project insert data with field assertions", () => {
|
||||
const result = insertProjectSchema.safeParse({
|
||||
name: "My Project",
|
||||
userId: "user-123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("My Project");
|
||||
expect(result.data.userId).toBe("user-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept optional sortOrder", () => {
|
||||
const result = insertProjectSchema.safeParse({
|
||||
name: "My Project",
|
||||
userId: "user-123",
|
||||
sortOrder: 5,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectProjectSchema", () => {
|
||||
it("should accept a complete project record", () => {
|
||||
const result = selectProjectSchema.safeParse({
|
||||
id: "proj-123",
|
||||
name: "My Project",
|
||||
userId: "user-123",
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
134
packages/api/tests/diagram/diagram-schema.test.ts
Normal file
134
packages/api/tests/diagram/diagram-schema.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { createDiagramSchema } from "../../src/modules/diagram/router";
|
||||
|
||||
describe("createDiagramSchema", () => {
|
||||
describe("title field", () => {
|
||||
it("should accept a valid title", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "My Diagram",
|
||||
type: "flowchart",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject empty title", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "",
|
||||
type: "flowchart",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject title over 255 characters", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "a".repeat(256),
|
||||
type: "flowchart",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept title at max length (255)", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "a".repeat(255),
|
||||
type: "flowchart",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject missing title", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
type: "flowchart",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("type field", () => {
|
||||
const validTypes = [
|
||||
"bpmn",
|
||||
"er",
|
||||
"orgchart",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"flowchart",
|
||||
] as const;
|
||||
|
||||
validTypes.forEach((type) => {
|
||||
it(`should accept type '${type}'`, () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject invalid type", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "invalid",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject missing type", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectId field", () => {
|
||||
it("should accept optional projectId", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "bpmn",
|
||||
projectId: "proj-123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.projectId).toBe("proj-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept missing projectId", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "bpmn",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.projectId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete valid input", () => {
|
||||
it("should parse valid input correctly", () => {
|
||||
const input = {
|
||||
title: "System Architecture",
|
||||
type: "architecture" as const,
|
||||
projectId: "proj-abc",
|
||||
};
|
||||
const result = createDiagramSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(input);
|
||||
}
|
||||
});
|
||||
|
||||
it("should strip unknown fields", () => {
|
||||
const result = createDiagramSchema.safeParse({
|
||||
title: "Test",
|
||||
type: "flowchart",
|
||||
unknownField: "should be stripped",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).not.toHaveProperty("unknownField");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user