aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 17:37:08 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 17:37:08 +0900
commit797ef2fcfaa7ac63355c13809a644401a76250bc (patch)
tree70814083131572b31b72fdbaeea74b2d7aa60d91
parent943674471d062ea4494727ce308c8c429afd6f98 (diff)
downloadkioku-797ef2fcfaa7ac63355c13809a644401a76250bc.tar.gz
kioku-797ef2fcfaa7ac63355c13809a644401a76250bc.tar.zst
kioku-797ef2fcfaa7ac63355c13809a644401a76250bc.zip
feat(server): add Deck CRUD endpoints with tests
Implement complete Deck management API: - GET /api/decks - List user's decks - POST /api/decks - Create new deck - GET /api/decks/:id - Get deck by ID - PUT /api/decks/:id - Update deck - DELETE /api/decks/:id - Soft delete deck All endpoints require authentication and scope data to the authenticated user. Includes 22 unit tests covering success and error cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md4
-rw-r--r--src/server/index.ts5
-rw-r--r--src/server/repositories/deck.ts95
-rw-r--r--src/server/repositories/index.ts1
-rw-r--r--src/server/repositories/types.ts33
-rw-r--r--src/server/routes/decks.test.ts480
-rw-r--r--src/server/routes/decks.ts82
-rw-r--r--src/server/routes/index.ts1
8 files changed, 697 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 9b39fc7..819107e 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -80,8 +80,8 @@ Smaller features first to enable early MVP validation.
**Goal**: Create, edit, and delete decks
### Server API
-- [ ] Deck CRUD endpoints (GET, POST, PUT, DELETE)
-- [ ] Add tests
+- [x] Deck CRUD endpoints (GET, POST, PUT, DELETE)
+- [x] Add tests
### Frontend
- [ ] Deck list page (empty state, list view)
diff --git a/src/server/index.ts b/src/server/index.ts
index d157f74..bcedb4e 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 { errorHandler } from "./middleware/index.js";
-import { auth } from "./routes/index.js";
+import { auth, decks } from "./routes/index.js";
const app = new Hono();
@@ -17,7 +17,8 @@ const routes = app
.get("/api/health", (c) => {
return c.json({ status: "ok" }, 200);
})
- .route("/api/auth", auth);
+ .route("/api/auth", auth)
+ .route("/api/decks", decks);
export type AppType = typeof routes;
diff --git a/src/server/repositories/deck.ts b/src/server/repositories/deck.ts
new file mode 100644
index 0000000..77985a7
--- /dev/null
+++ b/src/server/repositories/deck.ts
@@ -0,0 +1,95 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { decks } from "../db/schema.js";
+import type { Deck, DeckRepository } from "./types.js";
+
+export const deckRepository: DeckRepository = {
+ async findByUserId(userId: string): Promise<Deck[]> {
+ const result = await db
+ .select()
+ .from(decks)
+ .where(and(eq(decks.userId, userId), isNull(decks.deletedAt)));
+ return result;
+ },
+
+ async findById(id: string, userId: string): Promise<Deck | undefined> {
+ const result = await db
+ .select()
+ .from(decks)
+ .where(
+ and(
+ eq(decks.id, id),
+ eq(decks.userId, userId),
+ isNull(decks.deletedAt),
+ ),
+ );
+ return result[0];
+ },
+
+ async create(data: {
+ userId: string;
+ name: string;
+ description?: string | null;
+ newCardsPerDay?: number;
+ }): Promise<Deck> {
+ const [deck] = await db
+ .insert(decks)
+ .values({
+ userId: data.userId,
+ name: data.name,
+ description: data.description ?? null,
+ newCardsPerDay: data.newCardsPerDay ?? 20,
+ })
+ .returning();
+ if (!deck) {
+ throw new Error("Failed to create deck");
+ }
+ return deck;
+ },
+
+ async update(
+ id: string,
+ userId: string,
+ data: {
+ name?: string;
+ description?: string | null;
+ newCardsPerDay?: number;
+ },
+ ): Promise<Deck | undefined> {
+ const result = await db
+ .update(decks)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ syncVersion: sql`${decks.syncVersion} + 1`,
+ })
+ .where(
+ and(
+ eq(decks.id, id),
+ eq(decks.userId, userId),
+ isNull(decks.deletedAt),
+ ),
+ )
+ .returning();
+ return result[0];
+ },
+
+ async softDelete(id: string, userId: string): Promise<boolean> {
+ const result = await db
+ .update(decks)
+ .set({
+ deletedAt: new Date(),
+ updatedAt: new Date(),
+ syncVersion: sql`${decks.syncVersion} + 1`,
+ })
+ .where(
+ and(
+ eq(decks.id, id),
+ eq(decks.userId, userId),
+ isNull(decks.deletedAt),
+ ),
+ )
+ .returning({ id: decks.id });
+ return result.length > 0;
+ },
+};
diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts
index 04b1f35..9a703ab 100644
--- a/src/server/repositories/index.ts
+++ b/src/server/repositories/index.ts
@@ -1,3 +1,4 @@
+export { deckRepository } from "./deck.js";
export { refreshTokenRepository } from "./refresh-token.js";
export * from "./types.js";
export { userRepository } from "./user.js";
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index 1ab4bdc..740fa29 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -44,3 +44,36 @@ export interface RefreshTokenRepository {
}): Promise<void>;
deleteById(id: string): Promise<void>;
}
+
+export interface Deck {
+ id: string;
+ userId: string;
+ name: string;
+ description: string | null;
+ newCardsPerDay: number;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+}
+
+export interface DeckRepository {
+ findByUserId(userId: string): Promise<Deck[]>;
+ findById(id: string, userId: string): Promise<Deck | undefined>;
+ create(data: {
+ userId: string;
+ name: string;
+ description?: string | null;
+ newCardsPerDay?: number;
+ }): Promise<Deck>;
+ update(
+ id: string,
+ userId: string,
+ data: {
+ name?: string;
+ description?: string | null;
+ newCardsPerDay?: number;
+ },
+ ): Promise<Deck | undefined>;
+ softDelete(id: string, userId: string): Promise<boolean>;
+}
diff --git a/src/server/routes/decks.test.ts b/src/server/routes/decks.test.ts
new file mode 100644
index 0000000..8f5be9d
--- /dev/null
+++ b/src/server/routes/decks.test.ts
@@ -0,0 +1,480 @@
+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 { Deck, DeckRepository } from "../repositories/index.js";
+import { createDecksRouter } from "./decks.js";
+
+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,
+ };
+}
+
+interface DeckResponse {
+ deck?: Deck;
+ decks?: Deck[];
+ success?: boolean;
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("GET /api/decks", () => {
+ let app: Hono;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockDeckRepo = createMockDeckRepo();
+ const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks", decksRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns empty array when user has no decks", async () => {
+ vi.mocked(mockDeckRepo.findByUserId).mockResolvedValue([]);
+
+ const res = await app.request("/api/decks", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.decks).toEqual([]);
+ expect(mockDeckRepo.findByUserId).toHaveBeenCalledWith("user-uuid-123");
+ });
+
+ it("returns user decks", async () => {
+ const mockDecks = [
+ createMockDeck({ id: "deck-1", name: "Deck 1" }),
+ createMockDeck({ id: "deck-2", name: "Deck 2" }),
+ ];
+ vi.mocked(mockDeckRepo.findByUserId).mockResolvedValue(mockDecks);
+
+ const res = await app.request("/api/decks", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.decks).toHaveLength(2);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request("/api/decks", {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/decks", () => {
+ let app: Hono;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockDeckRepo = createMockDeckRepo();
+ const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks", decksRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("creates a new deck with required fields", async () => {
+ const newDeck = createMockDeck({ name: "New Deck" });
+ vi.mocked(mockDeckRepo.create).mockResolvedValue(newDeck);
+
+ const res = await app.request("/api/decks", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "New Deck" }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.deck?.name).toBe("New Deck");
+ expect(mockDeckRepo.create).toHaveBeenCalledWith({
+ userId: "user-uuid-123",
+ name: "New Deck",
+ description: undefined,
+ newCardsPerDay: 20,
+ });
+ });
+
+ it("creates a new deck with all fields", async () => {
+ const newDeck = createMockDeck({
+ name: "Full Deck",
+ description: "Full description",
+ newCardsPerDay: 30,
+ });
+ vi.mocked(mockDeckRepo.create).mockResolvedValue(newDeck);
+
+ const res = await app.request("/api/decks", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: "Full Deck",
+ description: "Full description",
+ newCardsPerDay: 30,
+ }),
+ });
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.deck?.name).toBe("Full Deck");
+ expect(mockDeckRepo.create).toHaveBeenCalledWith({
+ userId: "user-uuid-123",
+ name: "Full Deck",
+ description: "Full description",
+ newCardsPerDay: 30,
+ });
+ });
+
+ it("returns 400 for missing name", async () => {
+ const res = await app.request("/api/decks", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for empty name", async () => {
+ const res = await app.request("/api/decks", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "" }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request("/api/decks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Test" }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("GET /api/decks/:id", () => {
+ let app: Hono;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockDeckRepo = createMockDeckRepo();
+ const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks", decksRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns deck by id", async () => {
+ const deckId = "a0000000-0000-4000-8000-000000000001";
+ const mockDeck = createMockDeck({ id: deckId });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(mockDeck);
+
+ const res = await app.request(`/api/decks/${deckId}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.deck?.id).toBe(deckId);
+ expect(mockDeckRepo.findById).toHaveBeenCalledWith(deckId, "user-uuid-123");
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ "/api/decks/00000000-0000-0000-0000-000000000000",
+ {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/decks/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-uuid-123", {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("PUT /api/decks/:id", () => {
+ let app: Hono;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockDeckRepo = createMockDeckRepo();
+ const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks", decksRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("updates deck name", async () => {
+ const updatedDeck = createMockDeck({ name: "Updated Name" });
+ vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck);
+
+ const res = await app.request(
+ "/api/decks/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 DeckResponse;
+ expect(body.deck?.name).toBe("Updated Name");
+ });
+
+ it("updates deck description", async () => {
+ const updatedDeck = createMockDeck({ description: "New description" });
+ vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck);
+
+ const res = await app.request(
+ "/api/decks/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ description: "New description" }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.deck?.description).toBe("New description");
+ });
+
+ it("updates newCardsPerDay", async () => {
+ const updatedDeck = createMockDeck({ newCardsPerDay: 50 });
+ vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck);
+
+ const res = await app.request(
+ "/api/decks/00000000-0000-0000-0000-000000000000",
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ newCardsPerDay: 50 }),
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.deck?.newCardsPerDay).toBe(50);
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.update).mockResolvedValue(undefined);
+
+ const res = await app.request(
+ "/api/decks/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 DeckResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/decks/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/decks/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/decks/:id", () => {
+ let app: Hono;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockDeckRepo = createMockDeckRepo();
+ const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks", decksRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("deletes deck successfully", async () => {
+ vi.mocked(mockDeckRepo.softDelete).mockResolvedValue(true);
+
+ const res = await app.request(
+ "/api/decks/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.success).toBe(true);
+ expect(mockDeckRepo.softDelete).toHaveBeenCalledWith(
+ "00000000-0000-0000-0000-000000000000",
+ "user-uuid-123",
+ );
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.softDelete).mockResolvedValue(false);
+
+ const res = await app.request(
+ "/api/decks/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` },
+ },
+ );
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as DeckResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid uuid", async () => {
+ const res = await app.request("/api/decks/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/00000000-0000-0000-0000-000000000000",
+ {
+ method: "DELETE",
+ },
+ );
+
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/server/routes/decks.ts b/src/server/routes/decks.ts
new file mode 100644
index 0000000..4604ea9
--- /dev/null
+++ b/src/server/routes/decks.ts
@@ -0,0 +1,82 @@
+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 } from "../repositories/index.js";
+import { createDeckSchema, updateDeckSchema } from "../schemas/index.js";
+
+export interface DeckDependencies {
+ deckRepo: DeckRepository;
+}
+
+const deckIdParamSchema = z.object({
+ id: z.string().uuid(),
+});
+
+export function createDecksRouter(deps: DeckDependencies) {
+ const { deckRepo } = deps;
+
+ return new Hono()
+ .use("*", authMiddleware)
+ .get("/", async (c) => {
+ const user = getAuthUser(c);
+ const decks = await deckRepo.findByUserId(user.id);
+ return c.json({ decks }, 200);
+ })
+ .post("/", zValidator("json", createDeckSchema), async (c) => {
+ const user = getAuthUser(c);
+ const data = c.req.valid("json");
+
+ const deck = await deckRepo.create({
+ userId: user.id,
+ name: data.name,
+ description: data.description,
+ newCardsPerDay: data.newCardsPerDay,
+ });
+
+ return c.json({ deck }, 201);
+ })
+ .get("/:id", zValidator("param", deckIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+
+ const deck = await deckRepo.findById(id, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ return c.json({ deck }, 200);
+ })
+ .put(
+ "/:id",
+ zValidator("param", deckIdParamSchema),
+ zValidator("json", updateDeckSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ const deck = await deckRepo.update(id, user.id, data);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ return c.json({ deck }, 200);
+ },
+ )
+ .delete("/:id", zValidator("param", deckIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { id } = c.req.valid("param");
+
+ const deleted = await deckRepo.softDelete(id, user.id);
+ if (!deleted) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ return c.json({ success: true }, 200);
+ });
+}
+
+export const decks = createDecksRouter({
+ deckRepo: deckRepository,
+});
diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts
index 0b89782..43a3a07 100644
--- a/src/server/routes/index.ts
+++ b/src/server/routes/index.ts
@@ -1 +1,2 @@
export { auth } from "./auth.js";
+export { decks } from "./decks.js";