From 0b0e7e802fcb50652c3e9912363d996a039d56d8 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 18:14:02 +0900 Subject: feat(server): add card CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement card management API with create, read, update, and delete operations. Cards are nested under decks (/api/decks/:deckId/cards) with deck ownership verification on all operations. - Add Card interface and CardRepository to repository types - Create cardRepository with CRUD operations and soft delete - Add card routes with Zod validation and auth middleware - Include 29 tests covering all endpoints and error cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/routes/cards.test.ts | 678 ++++++++++++++++++++++++++++++++++++++++ src/server/routes/cards.ts | 130 ++++++++ src/server/routes/index.ts | 1 + 3 files changed, 809 insertions(+) create mode 100644 src/server/routes/cards.test.ts create mode 100644 src/server/routes/cards.ts (limited to 'src/server/routes') diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts new file mode 100644 index 0000000..1d01cff --- /dev/null +++ b/src/server/routes/cards.test.ts @@ -0,0 +1,678 @@ +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, + CardRepository, + Deck, + DeckRepository, +} from "../repositories/index.js"; +import { createCardsRouter } from "./cards.js"; + +function createMockCardRepo(): CardRepository { + return { + findByDeckId: vi.fn(), + findById: 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 { + const now = Math.floor(Date.now() / 1000); + return sign( + { + sub: userId, + iat: now, + exp: now + 900, + }, + JWT_SECRET, + ); +} + +function createMockDeck(overrides: Partial = {}): 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 createMockCard(overrides: Partial = {}): Card { + return { + id: "card-uuid-123", + deckId: "deck-uuid-123", + 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 CardResponse { + card?: Card; + cards?: Card[]; + success?: boolean; + error?: { + code: string; + message: string; + }; +} + +const DECK_ID = "00000000-0000-4000-8000-000000000001"; +const CARD_ID = "00000000-0000-4000-8000-000000000002"; + +describe("GET /api/decks/:deckId/cards", () => { + let app: Hono; + let mockCardRepo: ReturnType; + let mockDeckRepo: ReturnType; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns empty array when deck has no cards", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findByDeckId).mockResolvedValue([]); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.cards).toEqual([]); + expect(mockDeckRepo.findById).toHaveBeenCalledWith( + DECK_ID, + "user-uuid-123", + ); + expect(mockCardRepo.findByDeckId).toHaveBeenCalledWith(DECK_ID); + }); + + it("returns cards for deck", async () => { + const mockCards = [ + createMockCard({ id: "card-1", front: "Q1", back: "A1" }), + createMockCard({ id: "card-2", front: "Q2", back: "A2" }), + ]; + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findByDeckId).mockResolvedValue(mockCards); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + 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}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + 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/cards", { + 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}/cards`, { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/decks/:deckId/cards", () => { + let app: Hono; + let mockCardRepo: ReturnType; + let mockDeckRepo: ReturnType; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("creates a new card", async () => { + const newCard = createMockCard({ + deckId: DECK_ID, + front: "New Question", + back: "New Answer", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.create).mockResolvedValue(newCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "New Question", back: "New Answer" }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("New Question"); + expect(body.card?.back).toBe("New Answer"); + expect(mockCardRepo.create).toHaveBeenCalledWith(DECK_ID, { + front: "New Question", + back: "New Answer", + }); + }); + + it("returns 400 for missing front", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ back: "Answer" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for missing back", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for empty front", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "", back: "Answer" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for empty back", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question", back: "" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question", back: "Answer" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ front: "Question", back: "Answer" }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType; + let mockDeckRepo: ReturnType; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns card by id", async () => { + const mockCard = createMockCard({ id: CARD_ID, deckId: DECK_ID }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findById).mockResolvedValue(mockCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.id).toBe(CARD_ID); + expect(mockCardRepo.findById).toHaveBeenCalledWith(CARD_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}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/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}/cards/${CARD_ID}`, { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType; + let mockDeckRepo: ReturnType; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("updates card front", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + front: "Updated Question", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Updated Question" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("Updated Question"); + }); + + it("updates card back", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + back: "Updated Answer", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ back: "Updated Answer" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.back).toBe("Updated Answer"); + }); + + it("updates both front and back", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + front: "New Q", + back: "New A", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "New Q", back: "New A" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("New Q"); + expect(body.card?.back).toBe("New A"); + expect(mockCardRepo.update).toHaveBeenCalledWith(CARD_ID, DECK_ID, { + front: "New Q", + back: "New A", + }); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/invalid-id`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType; + let mockDeckRepo: ReturnType; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("deletes card successfully", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.softDelete).mockResolvedValue(true); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.success).toBe(true); + expect(mockCardRepo.softDelete).toHaveBeenCalledWith(CARD_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}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.softDelete).mockResolvedValue(false); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/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}/cards/${CARD_ID}`, { + method: "DELETE", + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/server/routes/cards.ts b/src/server/routes/cards.ts new file mode 100644 index 0000000..6fb259b --- /dev/null +++ b/src/server/routes/cards.ts @@ -0,0 +1,130 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js"; +import { + type CardRepository, + cardRepository, + type DeckRepository, + deckRepository, +} from "../repositories/index.js"; +import { createCardSchema, updateCardSchema } from "../schemas/index.js"; + +export interface CardDependencies { + cardRepo: CardRepository; + deckRepo: DeckRepository; +} + +const deckIdParamSchema = z.object({ + deckId: z.string().uuid(), +}); + +const cardIdParamSchema = z.object({ + deckId: z.string().uuid(), + cardId: z.string().uuid(), +}); + +export function createCardsRouter(deps: CardDependencies) { + const { cardRepo, deckRepo } = deps; + + return new Hono() + .use("*", authMiddleware) + .get("/", zValidator("param", deckIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const cards = await cardRepo.findByDeckId(deckId); + return c.json({ cards }, 200); + }) + .post( + "/", + zValidator("param", deckIdParamSchema), + zValidator("json", createCardSchema), + async (c) => { + const user = getAuthUser(c); + const { deckId } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.create(deckId, { + front: data.front, + back: data.back, + }); + + return c.json({ card }, 201); + }, + ) + .get("/:cardId", zValidator("param", cardIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.findById(cardId, deckId); + if (!card) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ card }, 200); + }) + .put( + "/:cardId", + zValidator("param", cardIdParamSchema), + zValidator("json", updateCardSchema), + async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.update(cardId, deckId, data); + if (!card) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ card }, 200); + }, + ) + .delete("/:cardId", zValidator("param", cardIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const deleted = await cardRepo.softDelete(cardId, deckId); + if (!deleted) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ success: true }, 200); + }); +} + +export const cards = createCardsRouter({ + cardRepo: cardRepository, + deckRepo: deckRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 43a3a07..009f2c3 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,2 +1,3 @@ export { auth } from "./auth.js"; +export { cards } from "./cards.js"; export { decks } from "./decks.js"; -- cgit v1.2.3-70-g09d2