aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 01:10:56 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 01:10:56 +0900
commit352f7891588e9c33d27c2a189a414c5b4822e9fa (patch)
tree19b086ba815816ba0b6825c48c0bccdec4958a03
parent9165e1d82c49f247d0d2aa763395a199d9b3f8e4 (diff)
downloadkioku-352f7891588e9c33d27c2a189a414c5b4822e9fa.tar.gz
kioku-352f7891588e9c33d27c2a189a414c5b4822e9fa.tar.zst
kioku-352f7891588e9c33d27c2a189a414c5b4822e9fa.zip
feat(api): add Note API routes for CRUD operations
Add REST endpoints for notes under /api/decks/:deckId/notes: - GET / - List notes in deck - POST / - Create note with auto-generated cards - GET /:noteId - Get note with field values - PUT /:noteId - Update note field values - DELETE /:noteId - Delete note and its cards (cascade) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--src/server/index.ts11
-rw-r--r--src/server/routes/index.ts1
-rw-r--r--src/server/routes/notes.test.ts793
-rw-r--r--src/server/routes/notes.ts152
4 files changed, 956 insertions, 1 deletions
diff --git a/src/server/index.ts b/src/server/index.ts
index 91f76fb..52eab67 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2,7 +2,15 @@ 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, noteTypes, study, sync } from "./routes/index.js";
+import {
+ auth,
+ cards,
+ decks,
+ notes,
+ noteTypes,
+ study,
+ sync,
+} from "./routes/index.js";
const app = new Hono();
@@ -21,6 +29,7 @@ const routes = app
.route("/api/auth", auth)
.route("/api/decks", decks)
.route("/api/decks/:deckId/cards", cards)
+ .route("/api/decks/:deckId/notes", notes)
.route("/api/decks/:deckId/study", study)
.route("/api/note-types", noteTypes)
.route("/api/sync", sync);
diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts
index 74f239f..6b8d047 100644
--- a/src/server/routes/index.ts
+++ b/src/server/routes/index.ts
@@ -1,6 +1,7 @@
export { auth } from "./auth.js";
export { cards } from "./cards.js";
export { decks } from "./decks.js";
+export { notes } from "./notes.js";
export { noteTypes } from "./noteTypes.js";
export { study } from "./study.js";
export { sync } from "./sync.js";
diff --git a/src/server/routes/notes.test.ts b/src/server/routes/notes.test.ts
new file mode 100644
index 0000000..2ca08f9
--- /dev/null
+++ b/src/server/routes/notes.test.ts
@@ -0,0 +1,793 @@
+import { Hono } from "hono";
+import { sign } from "hono/jwt";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CardState } from "../db/schema.js";
+import { errorHandler } from "../middleware/index.js";
+import type {
+ Card,
+ CreateNoteResult,
+ Deck,
+ DeckRepository,
+ Note,
+ NoteFieldValue,
+ NoteRepository,
+ NoteWithFieldValues,
+} from "../repositories/index.js";
+import { createNotesRouter } from "./notes.js";
+
+function createMockNoteRepo(): NoteRepository {
+ return {
+ findByDeckId: vi.fn(),
+ findById: vi.fn(),
+ findByIdWithFieldValues: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ };
+}
+
+function createMockDeckRepo(): DeckRepository {
+ return {
+ findByUserId: vi.fn(),
+ findById: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: 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 createMockDeck(overrides: Partial<Deck> = {}): Deck {
+ return {
+ id: "deck-uuid-123",
+ userId: "user-uuid-123",
+ name: "Test Deck",
+ description: "Test description",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockNote(overrides: Partial<Note> = {}): Note {
+ return {
+ id: "note-uuid-123",
+ deckId: "deck-uuid-123",
+ noteTypeId: "note-type-uuid-123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockNoteFieldValue(
+ overrides: Partial<NoteFieldValue> = {},
+): NoteFieldValue {
+ return {
+ id: "field-value-uuid-123",
+ noteId: "note-uuid-123",
+ noteFieldTypeId: "field-type-uuid-123",
+ value: "Test value",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockCard(overrides: Partial<Card> = {}): Card {
+ return {
+ id: "card-uuid-123",
+ deckId: "deck-uuid-123",
+ noteId: "note-uuid-123",
+ isReversed: false,
+ front: "Question",
+ back: "Answer",
+ state: CardState.New,
+ due: new Date("2024-01-01"),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+interface NoteResponse {
+ note?: Note | NoteWithFieldValues;
+ notes?: Note[];
+ fieldValues?: NoteFieldValue[];
+ cards?: Card[];
+ success?: boolean;
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+const DECK_ID = "00000000-0000-4000-8000-000000000001";
+const NOTE_ID = "00000000-0000-4000-8000-000000000002";
+const NOTE_TYPE_ID = "00000000-0000-4000-8000-000000000003";
+const FIELD_TYPE_ID = "00000000-0000-4000-8000-000000000004";
+const BACK_FIELD_TYPE_ID = "00000000-0000-4000-8000-000000000005";
+
+describe("GET /api/decks/:deckId/notes", () => {
+ let app: Hono;
+ let mockNoteRepo: ReturnType<typeof createMockNoteRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteRepo = createMockNoteRepo();
+ mockDeckRepo = createMockDeckRepo();
+ const notesRouter = createNotesRouter({
+ noteRepo: mockNoteRepo,
+ deckRepo: mockDeckRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/notes", notesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns empty array when deck has no notes", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.findByDeckId).mockResolvedValue([]);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.notes).toEqual([]);
+ expect(mockDeckRepo.findById).toHaveBeenCalledWith(
+ DECK_ID,
+ "user-uuid-123",
+ );
+ expect(mockNoteRepo.findByDeckId).toHaveBeenCalledWith(DECK_ID);
+ });
+
+ it("returns notes for deck", async () => {
+ const mockNotes = [
+ createMockNote({ id: "note-1", deckId: DECK_ID }),
+ createMockNote({ id: "note-2", deckId: DECK_ID }),
+ ];
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.findByDeckId).mockResolvedValue(mockNotes);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.notes).toHaveLength(2);
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid deck uuid", async () => {
+ const res = await app.request("/api/decks/invalid-id/notes", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/decks/:deckId/notes", () => {
+ let app: Hono;
+ let mockNoteRepo: ReturnType<typeof createMockNoteRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteRepo = createMockNoteRepo();
+ mockDeckRepo = createMockDeckRepo();
+ const notesRouter = createNotesRouter({
+ noteRepo: mockNoteRepo,
+ deckRepo: mockDeckRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/notes", notesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("creates a new note with cards", async () => {
+ const mockResult: CreateNoteResult = {
+ note: createMockNote({
+ id: NOTE_ID,
+ deckId: DECK_ID,
+ noteTypeId: NOTE_TYPE_ID,
+ }),
+ fieldValues: [
+ createMockNoteFieldValue({
+ noteId: NOTE_ID,
+ noteFieldTypeId: FIELD_TYPE_ID,
+ value: "Front content",
+ }),
+ ],
+ cards: [
+ createMockCard({ id: "card-1", deckId: DECK_ID, noteId: NOTE_ID }),
+ ],
+ };
+
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.create).mockResolvedValue(mockResult);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Front content" },
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.note?.id).toBe(NOTE_ID);
+ expect(body.fieldValues).toHaveLength(1);
+ expect(body.cards).toHaveLength(1);
+ expect(mockNoteRepo.create).toHaveBeenCalledWith(DECK_ID, {
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Front content" },
+ });
+ });
+
+ it("creates a reversible note with two cards", async () => {
+ const mockResult: CreateNoteResult = {
+ note: createMockNote({
+ id: NOTE_ID,
+ deckId: DECK_ID,
+ noteTypeId: NOTE_TYPE_ID,
+ }),
+ fieldValues: [
+ createMockNoteFieldValue({ noteId: NOTE_ID, value: "Front" }),
+ createMockNoteFieldValue({ noteId: NOTE_ID, value: "Back" }),
+ ],
+ cards: [
+ createMockCard({ id: "card-1", isReversed: false }),
+ createMockCard({ id: "card-2", isReversed: true }),
+ ],
+ };
+
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.create).mockResolvedValue(mockResult);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Front", [BACK_FIELD_TYPE_ID]: "Back" },
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.cards).toHaveLength(2);
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Content" },
+ }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 404 for non-existent note type", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.create).mockRejectedValue(
+ new Error("Note type not found"),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Content" },
+ }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND");
+ });
+
+ it("returns 400 for missing noteTypeId", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Content" },
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing fields", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid noteTypeId uuid", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ noteTypeId: "invalid-uuid",
+ fields: { [FIELD_TYPE_ID]: "Content" },
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(`/api/decks/${DECK_ID}/notes`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ noteTypeId: NOTE_TYPE_ID,
+ fields: { [FIELD_TYPE_ID]: "Content" },
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("GET /api/decks/:deckId/notes/:noteId", () => {
+ let app: Hono;
+ let mockNoteRepo: ReturnType<typeof createMockNoteRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteRepo = createMockNoteRepo();
+ mockDeckRepo = createMockDeckRepo();
+ const notesRouter = createNotesRouter({
+ noteRepo: mockNoteRepo,
+ deckRepo: mockDeckRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/notes", notesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns note with field values", async () => {
+ const mockNoteWithFields: NoteWithFieldValues = {
+ ...createMockNote({ id: NOTE_ID, deckId: DECK_ID }),
+ fieldValues: [
+ createMockNoteFieldValue({ noteId: NOTE_ID, value: "Front" }),
+ createMockNoteFieldValue({ noteId: NOTE_ID, value: "Back" }),
+ ],
+ };
+
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.findByIdWithFieldValues).mockResolvedValue(
+ mockNoteWithFields,
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.note?.id).toBe(NOTE_ID);
+ expect((body.note as NoteWithFieldValues)?.fieldValues).toHaveLength(2);
+ expect(mockNoteRepo.findByIdWithFieldValues).toHaveBeenCalledWith(
+ NOTE_ID,
+ DECK_ID,
+ );
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 404 for non-existent note", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.findByIdWithFieldValues).mockResolvedValue(
+ undefined,
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("NOTE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid note uuid", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/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/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("PUT /api/decks/:deckId/notes/:noteId", () => {
+ let app: Hono;
+ let mockNoteRepo: ReturnType<typeof createMockNoteRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteRepo = createMockNoteRepo();
+ mockDeckRepo = createMockDeckRepo();
+ const notesRouter = createNotesRouter({
+ noteRepo: mockNoteRepo,
+ deckRepo: mockDeckRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/notes", notesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("updates note field values", async () => {
+ const updatedNote: NoteWithFieldValues = {
+ ...createMockNote({ id: NOTE_ID, deckId: DECK_ID }),
+ fieldValues: [
+ createMockNoteFieldValue({
+ noteId: NOTE_ID,
+ noteFieldTypeId: FIELD_TYPE_ID,
+ value: "Updated Front",
+ }),
+ ],
+ };
+
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.update).mockResolvedValue(updatedNote);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Updated Front" },
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.note?.id).toBe(NOTE_ID);
+ expect((body.note as NoteWithFieldValues)?.fieldValues?.[0]?.value).toBe(
+ "Updated Front",
+ );
+ expect(mockNoteRepo.update).toHaveBeenCalledWith(NOTE_ID, DECK_ID, {
+ [FIELD_TYPE_ID]: "Updated Front",
+ });
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Updated" },
+ }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 404 for non-existent note", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.update).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Updated" },
+ }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("NOTE_NOT_FOUND");
+ });
+
+ it("returns 400 for missing fields", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid note uuid", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/invalid-id`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Updated" },
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ fields: { [FIELD_TYPE_ID]: "Updated" },
+ }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("DELETE /api/decks/:deckId/notes/:noteId", () => {
+ let app: Hono;
+ let mockNoteRepo: ReturnType<typeof createMockNoteRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockNoteRepo = createMockNoteRepo();
+ mockDeckRepo = createMockDeckRepo();
+ const notesRouter = createNotesRouter({
+ noteRepo: mockNoteRepo,
+ deckRepo: mockDeckRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/notes", notesRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("deletes note and its cards successfully", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.softDelete).mockResolvedValue(true);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.success).toBe(true);
+ expect(mockNoteRepo.softDelete).toHaveBeenCalledWith(NOTE_ID, DECK_ID);
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 404 for non-existent note", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockNoteRepo.softDelete).mockResolvedValue(false);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as NoteResponse;
+ expect(body.error?.code).toBe("NOTE_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid note uuid", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/notes/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/decks/${DECK_ID}/notes/${NOTE_ID}`, {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/server/routes/notes.ts b/src/server/routes/notes.ts
new file mode 100644
index 0000000..16ffb09
--- /dev/null
+++ b/src/server/routes/notes.ts
@@ -0,0 +1,152 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js";
+import {
+ type DeckRepository,
+ deckRepository,
+ type NoteRepository,
+ noteRepository,
+} from "../repositories/index.js";
+import { createNoteSchema, updateNoteSchema } from "../schemas/index.js";
+
+export interface NoteDependencies {
+ noteRepo: NoteRepository;
+ deckRepo: DeckRepository;
+}
+
+const deckIdParamSchema = z.object({
+ deckId: z.uuid(),
+});
+
+const noteIdParamSchema = z.object({
+ deckId: z.uuid(),
+ noteId: z.uuid(),
+});
+
+export function createNotesRouter(deps: NoteDependencies) {
+ const { noteRepo, deckRepo } = deps;
+
+ return (
+ new Hono()
+ .use("*", authMiddleware)
+ // List notes in deck
+ .get("/", zValidator("param", deckIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { deckId } = c.req.valid("param");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const notes = await noteRepo.findByDeckId(deckId);
+ return c.json({ notes }, 200);
+ })
+ // Create note (auto-generates cards)
+ .post(
+ "/",
+ zValidator("param", deckIdParamSchema),
+ zValidator("json", createNoteSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { deckId } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ try {
+ const result = await noteRepo.create(deckId, {
+ noteTypeId: data.noteTypeId,
+ fields: data.fields,
+ });
+
+ return c.json(
+ {
+ note: result.note,
+ fieldValues: result.fieldValues,
+ cards: result.cards,
+ },
+ 201,
+ );
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message === "Note type not found"
+ ) {
+ throw Errors.notFound(
+ "Note type not found",
+ "NOTE_TYPE_NOT_FOUND",
+ );
+ }
+ throw error;
+ }
+ },
+ )
+ // Get note with field values
+ .get("/:noteId", zValidator("param", noteIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { deckId, noteId } = c.req.valid("param");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const note = await noteRepo.findByIdWithFieldValues(noteId, deckId);
+ if (!note) {
+ throw Errors.notFound("Note not found", "NOTE_NOT_FOUND");
+ }
+
+ return c.json({ note }, 200);
+ })
+ // Update note field values
+ .put(
+ "/:noteId",
+ zValidator("param", noteIdParamSchema),
+ zValidator("json", updateNoteSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { deckId, noteId } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const note = await noteRepo.update(noteId, deckId, data.fields);
+ if (!note) {
+ throw Errors.notFound("Note not found", "NOTE_NOT_FOUND");
+ }
+
+ return c.json({ note }, 200);
+ },
+ )
+ // Delete note and its cards
+ .delete("/:noteId", zValidator("param", noteIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { deckId, noteId } = c.req.valid("param");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const deleted = await noteRepo.softDelete(noteId, deckId);
+ if (!deleted) {
+ throw Errors.notFound("Note not found", "NOTE_NOT_FOUND");
+ }
+
+ return c.json({ success: true }, 200);
+ })
+ );
+}
+
+export const notes = createNotesRouter({
+ noteRepo: noteRepository,
+ deckRepo: deckRepository,
+});