aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--docs/dev/roadmap.md4
-rw-r--r--src/server/index.ts3
-rw-r--r--src/server/routes/index.ts1
-rw-r--r--src/server/routes/noteTypes.test.ts1026
-rw-r--r--src/server/routes/noteTypes.ts221
5 files changed, 1252 insertions, 3 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 730eca5..957f4c4 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -169,13 +169,13 @@ Create these as default note types for each user:
### Phase 3: Server API Routes
**Tasks:**
-- [ ] Add NoteType routes
+- [x] Add NoteType routes
- `GET /api/note-types` - List user's note types
- `POST /api/note-types` - Create note type
- `GET /api/note-types/:id` - Get note type with fields
- `PUT /api/note-types/:id` - Update note type (name, front_template, back_template, is_reversible)
- `DELETE /api/note-types/:id` - Soft delete
-- [ ] Add NoteFieldType routes (nested under note-types)
+- [x] Add NoteFieldType routes (nested under note-types)
- `POST /api/note-types/:id/fields` - Add field
- `PUT /api/note-types/:id/fields/:fieldId` - Update field
- `DELETE /api/note-types/:id/fields/:fieldId` - Remove field
diff --git a/src/server/index.ts b/src/server/index.ts
index ad7f48a..91f76fb 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { createCorsMiddleware, errorHandler } from "./middleware/index.js";
-import { auth, cards, decks, study, sync } from "./routes/index.js";
+import { auth, cards, decks, noteTypes, study, sync } from "./routes/index.js";
const app = new Hono();
@@ -22,6 +22,7 @@ const routes = app
.route("/api/decks", decks)
.route("/api/decks/:deckId/cards", cards)
.route("/api/decks/:deckId/study", study)
+ .route("/api/note-types", noteTypes)
.route("/api/sync", sync);
export type AppType = typeof routes;
diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts
index 08f42df..74f239f 100644
--- a/src/server/routes/index.ts
+++ b/src/server/routes/index.ts
@@ -1,5 +1,6 @@
export { auth } from "./auth.js";
export { cards } from "./cards.js";
export { decks } from "./decks.js";
+export { noteTypes } from "./noteTypes.js";
export { study } from "./study.js";
export { sync } from "./sync.js";
diff --git a/src/server/routes/noteTypes.test.ts b/src/server/routes/noteTypes.test.ts
new file mode 100644
index 0000000..ccc29af
--- /dev/null
+++ b/src/server/routes/noteTypes.test.ts
@@ -0,0 +1,1026 @@
+import { Hono } from "hono";
+import { sign } from "hono/jwt";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { errorHandler } from "../middleware/index.js";
+import type {
+ NoteFieldType,
+ NoteFieldTypeRepository,
+ NoteType,
+ NoteTypeRepository,
+ NoteTypeWithFields,
+} from "../repositories/index.js";
+import { createNoteTypesRouter } from "./noteTypes.js";
+
+function createMockNoteTypeRepo(): NoteTypeRepository {
+ return {
+ findByUserId: vi.fn(),
+ findById: vi.fn(),
+ findByIdWithFields: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ hasNotes: vi.fn(),
+ };
+}
+
+function createMockNoteFieldTypeRepo(): NoteFieldTypeRepository {
+ return {
+ findByNoteTypeId: vi.fn(),
+ findById: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ reorder: vi.fn(),
+ hasNoteFieldValues: vi.fn(),
+ };
+}
+
+const JWT_SECRET = process.env.JWT_SECRET || "test-secret";
+
+async function createTestToken(userId: string): Promise<string> {
+ const now = Math.floor(Date.now() / 1000);
+ return sign(
+ {
+ sub: userId,
+ iat: now,
+ exp: now + 900,
+ },
+ JWT_SECRET,
+ );
+}
+
+function createMockNoteType(overrides: Partial<NoteType> = {}): NoteType {
+ return {
+ id: "note-type-uuid-123",
+ userId: "user-uuid-123",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockNoteFieldType(
+ overrides: Partial<NoteFieldType> = {},
+): NoteFieldType {
+ return {
+ id: "field-uuid-123",
+ noteTypeId: "note-type-uuid-123",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+interface NoteTypeResponse {
+ noteType?: NoteType | NoteTypeWithFields;
+ noteTypes?: NoteType[];
+ field?: NoteFieldType;
+ fields?: NoteFieldType[];
+ success?: boolean;
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("GET /api/note-types", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns empty array when user has no note types", async () => {
+ vi.mocked(mockNoteTypeRepo.findByUserId).mockResolvedValue([]);
+
+ const res = await app.request("/api/note-types", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteTypes).toEqual([]);
+ expect(mockNoteTypeRepo.findByUserId).toHaveBeenCalledWith("user-uuid-123");
+ });
+
+ it("returns user note types", async () => {
+ const mockNoteTypes = [
+ createMockNoteType({ id: "type-1", name: "Basic" }),
+ createMockNoteType({
+ id: "type-2",
+ name: "Basic (and reversed)",
+ isReversible: true,
+ }),
+ ];
+ vi.mocked(mockNoteTypeRepo.findByUserId).mockResolvedValue(mockNoteTypes);
+
+ const res = await app.request("/api/note-types", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteTypes).toHaveLength(2);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request("/api/note-types", {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/note-types", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("creates a new note type", async () => {
+ const newNoteType = createMockNoteType({ name: "Custom Type" });
+ vi.mocked(mockNoteTypeRepo.create).mockResolvedValue(newNoteType);
+
+ const res = await app.request("/api/note-types", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: "Custom Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.name).toBe("Custom Type");
+ expect(mockNoteTypeRepo.create).toHaveBeenCalledWith({
+ userId: "user-uuid-123",
+ name: "Custom Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ });
+ });
+
+ it("creates a reversible note type", async () => {
+ const newNoteType = createMockNoteType({
+ name: "Reversible",
+ isReversible: true,
+ });
+ vi.mocked(mockNoteTypeRepo.create).mockResolvedValue(newNoteType);
+
+ const res = await app.request("/api/note-types", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: "Reversible",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.isReversible).toBe(true);
+ });
+
+ it("returns 400 for missing required fields", async () => {
+ const res = await app.request("/api/note-types", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Test" }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request("/api/note-types", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Test",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("GET /api/note-types/:id", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns note type with fields", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const mockNoteType: NoteTypeWithFields = {
+ ...createMockNoteType({ id: noteTypeId }),
+ fields: [
+ createMockNoteFieldType({ id: "field-1", name: "Front", order: 0 }),
+ createMockNoteFieldType({ id: "field-2", name: "Back", order: 1 }),
+ ],
+ };
+ vi.mocked(mockNoteTypeRepo.findByIdWithFields).mockResolvedValue(
+ mockNoteType,
+ );
+
+ const res = await app.request(`/api/note-types/${noteTypeId}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.id).toBe(noteTypeId);
+ expect((body.noteType as NoteTypeWithFields)?.fields).toHaveLength(2);
+ expect(mockNoteTypeRepo.findByIdWithFields).toHaveBeenCalledWith(
+ noteTypeId,
+ "user-uuid-123",
+ );
+ });
+
+ it("returns 404 for non-existent note type", async () => {
+ vi.mocked(mockNoteTypeRepo.findByIdWithFields).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/note-types/invalid-id", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request("/api/note-types/note-type-uuid-123", {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("PUT /api/note-types/:id", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("updates note type name", async () => {
+ const updatedNoteType = createMockNoteType({ name: "Updated Name" });
+ vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Updated Name" }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.name).toBe("Updated Name");
+ });
+
+ it("updates note type templates", async () => {
+ const updatedNoteType = createMockNoteType({
+ frontTemplate: "Q: {{Front}}",
+ backTemplate: "A: {{Back}}",
+ });
+ vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ frontTemplate: "Q: {{Front}}",
+ backTemplate: "A: {{Back}}",
+ }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.frontTemplate).toBe("Q: {{Front}}");
+ expect(body.noteType?.backTemplate).toBe("A: {{Back}}");
+ });
+
+ it("updates isReversible flag", async () => {
+ const updatedNoteType = createMockNoteType({ isReversible: true });
+ vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ isReversible: true }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.noteType?.isReversible).toBe(true);
+ });
+
+ it("returns 404 for non-existent note type", async () => {
+ vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Test" }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/note-types/invalid-id", {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Test" }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Test" }),
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("DELETE /api/note-types/:id", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("deletes note type successfully", async () => {
+ vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(false);
+ vi.mocked(mockNoteTypeRepo.softDelete).mockResolvedValue(true);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.success).toBe(true);
+ expect(mockNoteTypeRepo.softDelete).toHaveBeenCalledWith(
+ "00000000-0000-0000-0000-000000000000",
+ "user-uuid-123",
+ );
+ });
+
+ it("returns 409 when note type has notes", async () => {
+ vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(true);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_HAS_NOTES");
+ });
+
+ it("returns 404 for non-existent note type", async () => {
+ vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(false);
+ vi.mocked(mockNoteTypeRepo.softDelete).mockResolvedValue(false);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/note-types/invalid-id", {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/note-types/:id/fields", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("creates a new field", async () => {
+ const noteTypeId = "00000000-0000-0000-0000-000000000000";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+ const newField = createMockNoteFieldType({
+ noteTypeId,
+ name: "Extra",
+ order: 2,
+ });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.create).mockResolvedValue(newField);
+
+ const res = await app.request(`/api/note-types/${noteTypeId}/fields`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Extra", order: 2 }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.field?.name).toBe("Extra");
+ expect(mockNoteFieldTypeRepo.create).toHaveBeenCalledWith(noteTypeId, {
+ name: "Extra",
+ order: 2,
+ fieldType: "text",
+ });
+ });
+
+ it("returns 404 when note type not found", async () => {
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000/fields",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Extra", order: 2 }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for missing required fields", async () => {
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000/fields",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Extra" }),
+ },
+ );
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(
+ "/api/note-types/00000000-0000-0000-0000-000000000000/fields",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Extra", order: 2 }),
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("PUT /api/note-types/:id/fields/:fieldId", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("updates field name", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+ const updatedField = createMockNoteFieldType({
+ id: fieldId,
+ noteTypeId,
+ name: "Updated Name",
+ });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.update).mockResolvedValue(updatedField);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Updated Name" }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.field?.name).toBe("Updated Name");
+ });
+
+ it("returns 404 when note type not found", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Updated" }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 404 when field not found", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.update).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "Updated" }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("FIELD_NOT_FOUND");
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(
+ "/api/note-types/a0000000-0000-4000-8000-000000000001/fields/b0000000-0000-4000-8000-000000000002",
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Updated" }),
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("DELETE /api/note-types/:id/fields/:fieldId", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("deletes field successfully", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(
+ false,
+ );
+ vi.mocked(mockNoteFieldTypeRepo.softDelete).mockResolvedValue(true);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.success).toBe(true);
+ expect(mockNoteFieldTypeRepo.softDelete).toHaveBeenCalledWith(
+ fieldId,
+ noteTypeId,
+ );
+ });
+
+ it("returns 409 when field has values", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(true);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("FIELD_HAS_VALUES");
+ });
+
+ it("returns 404 when note type not found", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 404 when field not found", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId = "b0000000-0000-4000-8000-000000000002";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(
+ false,
+ );
+ vi.mocked(mockNoteFieldTypeRepo.softDelete).mockResolvedValue(false);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/${fieldId}`,
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("FIELD_NOT_FOUND");
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(
+ "/api/note-types/a0000000-0000-4000-8000-000000000001/fields/b0000000-0000-4000-8000-000000000002",
+ {
+ method: "DELETE",
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("PUT /api/note-types/:id/fields/reorder", () => {
+ let app: Hono;
+ let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>;
+ let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteTypeRepo = createMockNoteTypeRepo();
+ mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo();
+ const noteTypesRouter = createNoteTypesRouter({
+ noteTypeRepo: mockNoteTypeRepo,
+ noteFieldTypeRepo: mockNoteFieldTypeRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/note-types", noteTypesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("reorders fields successfully", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId1 = "b0000000-0000-4000-8000-000000000002";
+ const fieldId2 = "c0000000-0000-4000-8000-000000000003";
+ const mockNoteType = createMockNoteType({ id: noteTypeId });
+ const reorderedFields = [
+ createMockNoteFieldType({ id: fieldId2, order: 0 }),
+ createMockNoteFieldType({ id: fieldId1, order: 1 }),
+ ];
+
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType);
+ vi.mocked(mockNoteFieldTypeRepo.reorder).mockResolvedValue(reorderedFields);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/reorder`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ fieldIds: [fieldId2, fieldId1] }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.fields).toHaveLength(2);
+ expect(mockNoteFieldTypeRepo.reorder).toHaveBeenCalledWith(noteTypeId, [
+ fieldId2,
+ fieldId1,
+ ]);
+ });
+
+ it("returns 404 when note type not found", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId1 = "b0000000-0000-4000-8000-000000000002";
+ const fieldId2 = "c0000000-0000-4000-8000-000000000003";
+ vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/reorder`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fieldIds: [fieldId1, fieldId2],
+ }),
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteTypeResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid fieldIds", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/reorder`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ fieldIds: ["invalid-id"] }),
+ },
+ );
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const noteTypeId = "a0000000-0000-4000-8000-000000000001";
+ const fieldId1 = "b0000000-0000-4000-8000-000000000002";
+ const fieldId2 = "c0000000-0000-4000-8000-000000000003";
+ const res = await app.request(
+ `/api/note-types/${noteTypeId}/fields/reorder`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ fieldIds: [fieldId1, fieldId2],
+ }),
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/server/routes/noteTypes.ts b/src/server/routes/noteTypes.ts
new file mode 100644
index 0000000..7ab5aa0
--- /dev/null
+++ b/src/server/routes/noteTypes.ts
@@ -0,0 +1,221 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js";
+import {
+ type NoteFieldTypeRepository,
+ type NoteTypeRepository,
+ noteFieldTypeRepository,
+ noteTypeRepository,
+} from "../repositories/index.js";
+import {
+ createNoteFieldTypeSchema,
+ createNoteTypeSchema,
+ updateNoteFieldTypeSchema,
+ updateNoteTypeSchema,
+} from "../schemas/index.js";
+
+export interface NoteTypeDependencies {
+ noteTypeRepo: NoteTypeRepository;
+ noteFieldTypeRepo: NoteFieldTypeRepository;
+}
+
+const noteTypeIdParamSchema = z.object({
+ id: z.uuid(),
+});
+
+const noteTypeFieldIdParamSchema = z.object({
+ id: z.uuid(),
+ fieldId: z.uuid(),
+});
+
+const reorderFieldsSchema = z.object({
+ fieldIds: z.array(z.uuid()),
+});
+
+export function createNoteTypesRouter(deps: NoteTypeDependencies) {
+ const { noteTypeRepo, noteFieldTypeRepo } = deps;
+
+ return (
+ new Hono()
+ .use("*", authMiddleware)
+ // List user's note types
+ .get("/", async (c) => {
+ const user = getAuthUser(c);
+ const noteTypes = await noteTypeRepo.findByUserId(user.id);
+ return c.json({ noteTypes }, 200);
+ })
+ // Create note type
+ .post("/", zValidator("json", createNoteTypeSchema), async (c) => {
+ const user = getAuthUser(c);
+ const data = c.req.valid("json");
+
+ const noteType = await noteTypeRepo.create({
+ userId: user.id,
+ name: data.name,
+ frontTemplate: data.frontTemplate,
+ backTemplate: data.backTemplate,
+ isReversible: data.isReversible,
+ });
+
+ return c.json({ noteType }, 201);
+ })
+ // Get note type with fields
+ .get("/:id", zValidator("param", noteTypeIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+
+ const noteType = await noteTypeRepo.findByIdWithFields(id, user.id);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ return c.json({ noteType }, 200);
+ })
+ // Update note type
+ .put(
+ "/:id",
+ zValidator("param", noteTypeIdParamSchema),
+ zValidator("json", updateNoteTypeSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ const noteType = await noteTypeRepo.update(id, user.id, data);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ return c.json({ noteType }, 200);
+ },
+ )
+ // Delete note type (soft delete)
+ .delete("/:id", zValidator("param", noteTypeIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+
+ // Check if there are notes referencing this note type
+ const hasNotes = await noteTypeRepo.hasNotes(id, user.id);
+ if (hasNotes) {
+ throw Errors.conflict(
+ "Cannot delete note type with existing notes",
+ "NOTE_TYPE_HAS_NOTES",
+ );
+ }
+
+ const deleted = await noteTypeRepo.softDelete(id, user.id);
+ if (!deleted) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ return c.json({ success: true }, 200);
+ })
+ // Add field to note type
+ .post(
+ "/:id/fields",
+ zValidator("param", noteTypeIdParamSchema),
+ zValidator("json", createNoteFieldTypeSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ // Verify note type exists and belongs to user
+ const noteType = await noteTypeRepo.findById(id, user.id);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ const field = await noteFieldTypeRepo.create(id, {
+ name: data.name,
+ order: data.order,
+ fieldType: data.fieldType,
+ });
+
+ return c.json({ field }, 201);
+ },
+ )
+ // Reorder fields (must come before /:id/fields/:fieldId to avoid matching "reorder" as fieldId)
+ .put(
+ "/:id/fields/reorder",
+ zValidator("param", noteTypeIdParamSchema),
+ zValidator("json", reorderFieldsSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+ const { fieldIds } = c.req.valid("json");
+
+ // Verify note type exists and belongs to user
+ const noteType = await noteTypeRepo.findById(id, user.id);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ const fields = await noteFieldTypeRepo.reorder(id, fieldIds);
+
+ return c.json({ fields }, 200);
+ },
+ )
+ // Update field
+ .put(
+ "/:id/fields/:fieldId",
+ zValidator("param", noteTypeFieldIdParamSchema),
+ zValidator("json", updateNoteFieldTypeSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id, fieldId } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ // Verify note type exists and belongs to user
+ const noteType = await noteTypeRepo.findById(id, user.id);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ const field = await noteFieldTypeRepo.update(fieldId, id, data);
+ if (!field) {
+ throw Errors.notFound("Field not found", "FIELD_NOT_FOUND");
+ }
+
+ return c.json({ field }, 200);
+ },
+ )
+ // Delete field
+ .delete(
+ "/:id/fields/:fieldId",
+ zValidator("param", noteTypeFieldIdParamSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id, fieldId } = c.req.valid("param");
+
+ // Verify note type exists and belongs to user
+ const noteType = await noteTypeRepo.findById(id, user.id);
+ if (!noteType) {
+ throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND");
+ }
+
+ // Check if there are note field values referencing this field
+ const hasValues = await noteFieldTypeRepo.hasNoteFieldValues(fieldId);
+ if (hasValues) {
+ throw Errors.conflict(
+ "Cannot delete field with existing values",
+ "FIELD_HAS_VALUES",
+ );
+ }
+
+ const deleted = await noteFieldTypeRepo.softDelete(fieldId, id);
+ if (!deleted) {
+ throw Errors.notFound("Field not found", "FIELD_NOT_FOUND");
+ }
+
+ return c.json({ success: true }, 200);
+ },
+ )
+ );
+}
+
+export const noteTypes = createNoteTypesRouter({
+ noteTypeRepo: noteTypeRepository,
+ noteFieldTypeRepo: noteFieldTypeRepository,
+});