Skip to content

Commit 0edf9da

Browse files
cursoragentOjoxux
andcommitted
feat: 型でドメインモデルを表現 - Branded ID types と単一定義元
- app/types/domain.ts を新規追加 - Brand<T, B> ユーティリティ型 - UserId, BookId, TagId, SessionId のブランド型 - OAuthProvider, BookStatus, BookSortOrder の単一定義元 - 全レイヤーでブランド型を使用 - DB リポジトリ: UserId/BookId/TagId を引数型に使用 - セッション: createSession が UserId を受け取り SessionId を返す - 認証ミドルウェア: UserId で統一 - マッパー: crypto.randomUUID() を BookId/TagId にキャスト - API ハンドラー: URL パラメータ境界で as BookId 等でキャスト - 重複を解消 - OAuthProvider: types/api.ts と schemas/auth.ts で domain から再エクスポート - BookStatus: database.ts で domain から再エクスポート、domain/book.ts も同様 - テストヘルパーと全テストファイルをブランド型に対応 Co-authored-by: Jou Okuyama <Ojoxux@users.noreply.github.com>
1 parent 5cd256d commit 0edf9da

27 files changed

Lines changed: 204 additions & 149 deletions

app/routes/books/[id]/edit.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Layout } from "../../../components/layout/Layout";
44
import { Input, Textarea, Label } from "../../../components/ui/Input";
55
import { Button } from "../../../components/ui/Button";
66
import { bookRepo } from "../../../server/db/repositories";
7+
import type { BookId } from "../../../types/domain";
78

89
export default createRoute(async (c) => {
910
const authResult = await requirePageAuth(c);
@@ -13,7 +14,7 @@ export default createRoute(async (c) => {
1314
const user = authResult;
1415
const sidebarExpanded = getSidebarExpanded(c);
1516

16-
const id = c.req.param("id")!;
17+
const id = c.req.param("id")! as BookId;
1718

1819
let book;
1920
try {

app/routes/books/[id]/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Layout } from "../../../components/layout/Layout";
44
import { BookCover } from "../../../components/book/BookCover";
55
import { Button } from "../../../components/ui/Button";
66
import { bookRepo } from "../../../server/db/repositories";
7+
import type { BookId } from "../../../types/domain";
78
import ProgressSlider from "../../../islands/ProgressSlider";
89
import StatusToggle from "../../../islands/StatusToggle";
910
import MemoEditor from "../../../islands/MemoEditor";
@@ -16,7 +17,7 @@ export default createRoute(async (c) => {
1617
const user = authResult;
1718
const sidebarExpanded = getSidebarExpanded(c);
1819

19-
const id = c.req.param("id")!;
20+
const id = c.req.param("id")! as BookId;
2021

2122
let book;
2223
try {

app/server/api/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from "hono";
22
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
33
import type { HonoContext } from "../../types/env";
4+
import type { UserId } from "../../types/domain";
45
import { userRepo } from "../db/repositories";
56
import { authMiddleware } from "../lib/auth";
67
import { validator, getValidated } from "../lib/validator";
@@ -102,7 +103,7 @@ app.get(
102103
// 新規ユーザー作成
103104
const now = new Date().toISOString();
104105
user = await userRepo.create(c.env.DB, {
105-
id: crypto.randomUUID(),
106+
id: crypto.randomUUID() as UserId,
106107
username: await generateUniqueUsername(c.env.DB, oauthUser.username),
107108
email: oauthUser.email,
108109
name: oauthUser.name,

app/server/api/books.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import api from "./index";
44
import { createTestUser, createTestSession, createTestBook } from "../../test/helpers";
55
import type { SuccessResponse, PaginatedResponse } from "../lib/response";
66
import type { BookResponse } from "../../types/database";
7+
import type { UserId, SessionId } from "../../types/domain";
78

89
describe("Books API Integration", () => {
9-
let userId: string;
10-
let sessionId: string;
10+
let userId: UserId;
11+
let sessionId: SessionId;
1112

1213
beforeEach(async () => {
1314
await env.DB.prepare("DELETE FROM book_tags").run();

app/server/api/books.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from "hono";
22
import type { HonoContext } from "../../types/env";
3+
import type { BookId } from "../../types/domain";
34
import { bookRepo, bookTagRepo } from "../db/repositories";
45
import { authMiddleware } from "../lib/auth";
56
import { validator, getValidated } from "../lib/validator";
@@ -46,10 +47,6 @@ app.get("/", validator("query", bookFilterSchema), async (c) => {
4647
return paginatedResponse(c, items, total, filter.limit ?? 20, filter.offset ?? 0);
4748
});
4849

49-
/**
50-
* 書籍詳細取得
51-
* GET /api/books/:id
52-
*/
5350
/**
5451
* 書籍統計取得
5552
* GET /api/books/stats
@@ -69,8 +66,8 @@ app.get("/:id", validator("param", bookIdSchema), async (c) => {
6966
const userId = c.get("userId");
7067
const { id } = getValidated<{ id: string }>(c, "param");
7168

72-
const book = await bookRepo.findById(c.env.DB, id, userId);
73-
const tags = await bookTagRepo.findTagsByBookId(c.env.DB, id);
69+
const book = await bookRepo.findById(c.env.DB, id as BookId, userId);
70+
const tags = await bookTagRepo.findTagsByBookId(c.env.DB, id as BookId);
7471

7572
return successResponse(c, {
7673
...toBookResponse(book),
@@ -106,7 +103,7 @@ app.put(
106103
const data = getValidated<UpdateBookInput>(c, "json");
107104

108105
const updateData = toBookUpdateInput(data);
109-
const book = await bookRepo.update(c.env.DB, id, userId, updateData);
106+
const book = await bookRepo.update(c.env.DB, id as BookId, userId, updateData);
110107

111108
return successResponse(c, toBookResponse(book));
112109
},
@@ -126,7 +123,7 @@ app.patch(
126123
const { currentPage } = getValidated<ProgressInput>(c, "json");
127124

128125
// 書籍を取得してページ数を検証
129-
const existingBook = await bookRepo.findById(c.env.DB, id, userId);
126+
const existingBook = await bookRepo.findById(c.env.DB, id as BookId, userId);
130127
const validation = bookDomain.validatePageProgress(currentPage, existingBook.page_count);
131128

132129
if (!validation.valid) {
@@ -141,7 +138,7 @@ app.patch(
141138
? new Date().toISOString()
142139
: existingBook.finished_at;
143140

144-
const book = await bookRepo.update(c.env.DB, id, userId, {
141+
const book = await bookRepo.update(c.env.DB, id as BookId, userId, {
145142
currentPage: currentPage,
146143
status: newStatus,
147144
finishedAt,
@@ -160,7 +157,7 @@ app.delete("/:id", validator("param", bookIdSchema), async (c) => {
160157
const userId = c.get("userId");
161158
const { id } = getValidated<{ id: string }>(c, "param");
162159

163-
await bookRepo.deleteById(c.env.DB, id, userId);
160+
await bookRepo.deleteById(c.env.DB, id as BookId, userId);
164161

165162
return successResponse(c, { deleted: true });
166163
});

app/server/api/schemas/auth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { regex, type } from "arktype";
22

3+
export type { OAuthProvider } from "../../../types/domain";
4+
35
/**
46
* OAuth認証プロバイダー
57
*/
@@ -26,7 +28,7 @@ export const oauthCallbackSchema = type({
2628
export const rakutenSearchSchema = type({
2729
query: "1 <= string <= 100",
2830
"limit?": "1 <= (number % 1) <= 10",
29-
"page?": "1 <= (number % 1) <= 1000", // 追加
31+
"page?": "1 <= (number % 1) <= 1000",
3032
});
3133

3234
/**
@@ -39,7 +41,6 @@ export const isbnSearchSchema = type({
3941
});
4042

4143
// 型エクスポート
42-
export type OAuthProvider = typeof oauthProviderSchema.infer;
4344
export type OAuthProviderParam = typeof oauthProviderParamSchema.infer;
4445
export type OAuthCallbackInput = typeof oauthCallbackSchema.infer;
4546
export type RakutenSearchInput = typeof rakutenSearchSchema.infer;

app/server/api/tags.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import {
99
} from "../../test/helpers";
1010
import type { SuccessResponse } from "../lib/response";
1111
import type { TagResponse } from "../../types/database";
12+
import type { UserId, SessionId } from "../../types/domain";
1213

1314
describe("Tags API Integration", () => {
14-
let userId: string;
15-
let sessionId: string;
15+
let userId: UserId;
16+
let sessionId: SessionId;
1617

1718
beforeEach(async () => {
1819
await env.DB.prepare("DELETE FROM book_tags").run();

app/server/api/tags.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from "hono";
22
import type { HonoContext } from "../../types/env";
3+
import type { BookId, TagId } from "../../types/domain";
34
import { tagRepo, bookTagRepo } from "../db/repositories";
45
import { authMiddleware } from "../lib/auth";
56
import { validator, getValidated } from "../lib/validator";
@@ -53,7 +54,7 @@ app.put("/:id", validator("param", tagIdSchema), validator("json", updateTagSche
5354
const { id } = getValidated<{ id: string }>(c, "param");
5455
const data = getValidated<UpdateTagInput>(c, "json");
5556

56-
const tag = await tagRepo.update(c.env.DB, id, userId, data.name);
57+
const tag = await tagRepo.update(c.env.DB, id as TagId, userId, data.name);
5758

5859
return successResponse(c, toTagResponse(tag));
5960
});
@@ -66,7 +67,7 @@ app.delete("/:id", validator("param", tagIdSchema), async (c) => {
6667
const userId = c.get("userId");
6768
const { id } = getValidated<{ id: string }>(c, "param");
6869

69-
await tagRepo.deleteById(c.env.DB, id, userId);
70+
await tagRepo.deleteById(c.env.DB, id as TagId, userId);
7071

7172
return successResponse(c, { deleted: true });
7273
});
@@ -84,7 +85,7 @@ app.post(
8485
const { bookId } = getValidated<{ bookId: string }>(c, "param");
8586
const { tagId } = getValidated<AddTagToBookInput>(c, "json");
8687

87-
await bookTagRepo.addTagToBook(c.env.DB, bookId, tagId, userId);
88+
await bookTagRepo.addTagToBook(c.env.DB, bookId as BookId, tagId as TagId, userId);
8889

8990
return successResponse(c, { added: true }, 201);
9091
},
@@ -99,7 +100,7 @@ app.delete("/books/:bookId/:tagId", async (c) => {
99100
const bookId = c.req.param("bookId");
100101
const tagId = c.req.param("tagId");
101102

102-
await bookTagRepo.removeTagFromBook(c.env.DB, bookId, tagId, userId);
103+
await bookTagRepo.removeTagFromBook(c.env.DB, bookId as BookId, tagId as TagId, userId);
103104

104105
return successResponse(c, { removed: true });
105106
});
@@ -111,7 +112,7 @@ app.delete("/books/:bookId/:tagId", async (c) => {
111112
app.get("/books/:bookId", async (c) => {
112113
const bookId = c.req.param("bookId");
113114

114-
const tags = await bookTagRepo.findTagsByBookId(c.env.DB, bookId);
115+
const tags = await bookTagRepo.findTagsByBookId(c.env.DB, bookId as BookId);
115116

116117
return successResponse(c, tags.map(toTagResponse));
117118
});

app/server/db/repositories/book.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import { env } from "cloudflare:test";
33
import * as bookRepo from "./book";
44
import { createTestUser, createTestBook } from "../../../test/helpers";
5+
import type { UserId, BookId } from "../../../types/domain";
56

67
describe("Book Repository", () => {
7-
let userId: string;
8+
let userId: UserId;
89

910
beforeEach(async () => {
1011
// テストデータをクリーンアップ
@@ -21,7 +22,7 @@ describe("Book Repository", () => {
2122
describe("create", () => {
2223
it("should create a book", async () => {
2324
const now = new Date().toISOString();
24-
const bookId = crypto.randomUUID();
25+
const bookId = crypto.randomUUID() as BookId;
2526

2627
const book = await bookRepo.create(env.DB, {
2728
id: bookId,
@@ -51,9 +52,9 @@ describe("Book Repository", () => {
5152
});
5253

5354
it("should throw NotFoundError for non-existent book", async () => {
54-
await expect(bookRepo.findById(env.DB, "non-existent", userId)).rejects.toThrow(
55-
"Book not found",
56-
);
55+
await expect(
56+
bookRepo.findById(env.DB, "non-existent" as BookId, userId),
57+
).rejects.toThrow("Book not found");
5758
});
5859

5960
it("should not return books from other users", async () => {

app/server/db/repositories/book.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { Book, BookInput, BookFilter, BookStats, BookStatus } from "../../../types/database";
1+
import type { Book, BookInput, BookFilter, BookStats } from "../../../types/database";
2+
import type { BookStatus } from "../../../types/domain";
23
import { NotFoundError, DatabaseError } from "../../lib/errors";
34

45
import type { Env } from "../../../types/env";
6+
import type { UserId, BookId } from "../../../types/domain";
57

68
type D1Database = Env["DB"];
79
type D1BindValue = string | number | null;
@@ -11,7 +13,7 @@ type D1BindValue = string | number | null;
1113
*/
1214
export async function findByUserId(
1315
db: D1Database,
14-
userId: string,
16+
userId: UserId,
1517
filter?: BookFilter & { limit?: number; offset?: number },
1618
): Promise<{ books: Book[]; total: number }> {
1719
try {
@@ -82,7 +84,7 @@ export async function findByUserId(
8284
/**
8385
* IDで書籍を取得
8486
*/
85-
export async function findById(db: D1Database, bookId: string, userId: string): Promise<Book> {
87+
export async function findById(db: D1Database, bookId: BookId, userId: UserId): Promise<Book> {
8688
try {
8789
const result = await db
8890
.prepare("SELECT * FROM books WHERE id = ? AND user_id = ?")
@@ -149,8 +151,8 @@ export async function create(db: D1Database, book: BookInput): Promise<Book> {
149151
*/
150152
export async function update(
151153
db: D1Database,
152-
bookId: string,
153-
userId: string,
154+
bookId: BookId,
155+
userId: UserId,
154156
data: Partial<BookInput>,
155157
): Promise<Book> {
156158
try {
@@ -210,8 +212,8 @@ export async function update(
210212
*/
211213
export async function updateProgress(
212214
db: D1Database,
213-
bookId: string,
214-
userId: string,
215+
bookId: BookId,
216+
userId: UserId,
215217
currentPage: number,
216218
status: BookStatus,
217219
): Promise<Book> {
@@ -240,7 +242,7 @@ export async function updateProgress(
240242
/**
241243
* 書籍を削除(関連するタグも削除)
242244
*/
243-
export async function deleteById(db: D1Database, bookId: string, userId: string): Promise<void> {
245+
export async function deleteById(db: D1Database, bookId: BookId, userId: UserId): Promise<void> {
244246
try {
245247
// 書籍の存在確認
246248
await findById(db, bookId, userId);
@@ -259,7 +261,7 @@ export async function deleteById(db: D1Database, bookId: string, userId: string)
259261
/**
260262
* ユーザーの統計情報を取得
261263
*/
262-
export async function getStats(db: D1Database, userId: string): Promise<BookStats> {
264+
export async function getStats(db: D1Database, userId: UserId): Promise<BookStats> {
263265
try {
264266
const result = await db
265267
.prepare(
@@ -294,8 +296,8 @@ export async function getStats(db: D1Database, userId: string): Promise<BookStat
294296
*/
295297
export async function belongsToUser(
296298
db: D1Database,
297-
bookId: string,
298-
userId: string,
299+
bookId: BookId,
300+
userId: UserId,
299301
): Promise<boolean> {
300302
try {
301303
const result = await db

0 commit comments

Comments
 (0)