create-einja-app 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/cli.js +852 -21
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/default/CLAUDE.md +27 -0
- package/templates/default/README.md +150 -45
- package/templates/default/apps/web/server/presentation/routes/userRoutes.ts +2 -5
- package/templates/default/apps/web/src/app/(authenticated)/data/_components/UserTable.tsx +110 -113
- package/templates/default/apps/web/src/app/(authenticated)/data/_components/UserTableContainer.tsx +5 -17
- package/templates/default/apps/web/src/app/(authenticated)/data/page.tsx +9 -7
- package/templates/default/apps/web/src/app/api/rpc/[[...route]]/route.ts +1 -1
- package/templates/default/apps/web/src/hooks/api/prefetch-users.ts +63 -0
- package/templates/default/apps/web/src/hooks/{use-users.ts → api/use-users.ts} +11 -46
- package/templates/default/apps/web/src/lib/api/parse-response.ts +114 -0
- package/templates/default/apps/web/src/shared/schemas/user.ts +36 -0
- package/templates/default/middleware.ts +0 -32
- package/templates/default/apps/web/src/lib/{api-client.ts → api/client.ts} +1 -1
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
2
|
-
import { prefetchUsers } from "@/hooks/
|
|
2
|
+
import { prefetchUsers } from "@/hooks/api/prefetch-users";
|
|
3
|
+
import type { PaginatedUserList } from "@/shared/schemas/user";
|
|
3
4
|
import { UserTableContainer } from "./_components/UserTableContainer";
|
|
4
5
|
|
|
5
6
|
export default async function DataPage() {
|
|
6
|
-
let initialData;
|
|
7
|
-
let error;
|
|
7
|
+
let initialData: PaginatedUserList | undefined;
|
|
8
|
+
let error: string | undefined;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const result = await prefetchUsers({ page: 1, limit: 100 });
|
|
11
|
+
if (result === null) {
|
|
12
|
+
error = "データの取得に失敗しました";
|
|
13
|
+
} else {
|
|
14
|
+
initialData = result;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
if (error) {
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* basePath: /api/rpc
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { userRoutes } from "@web/server/presentation/routes/userRoutes";
|
|
8
9
|
import { Hono } from "hono";
|
|
9
10
|
import { handle } from "hono/vercel";
|
|
10
|
-
import { userRoutes } from "@web/server/presentation/routes/userRoutes";
|
|
11
11
|
|
|
12
12
|
const app = new Hono().basePath("/api/rpc");
|
|
13
13
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prefetch-users.ts
|
|
3
|
+
*
|
|
4
|
+
* Server Component用のユーザーデータプリフェッチ関数
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseResponse } from "@/lib/api/parse-response";
|
|
8
|
+
import { type PaginatedUserList, paginatedUserListSchema } from "@/shared/schemas/user";
|
|
9
|
+
import { cookies, headers } from "next/headers";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* User filters type
|
|
13
|
+
*/
|
|
14
|
+
export interface UserFilters {
|
|
15
|
+
page?: number;
|
|
16
|
+
limit?: number;
|
|
17
|
+
search?: string;
|
|
18
|
+
status?: "active" | "inactive";
|
|
19
|
+
role?: "admin" | "user";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* prefetchUsers
|
|
24
|
+
*
|
|
25
|
+
* Server Component用のデータ取得関数
|
|
26
|
+
*/
|
|
27
|
+
export async function prefetchUsers(filters: UserFilters = {}): Promise<PaginatedUserList | null> {
|
|
28
|
+
try {
|
|
29
|
+
// リクエストヘッダーからホスト情報を取得
|
|
30
|
+
const headersList = await headers();
|
|
31
|
+
const host = headersList.get("host") || "localhost:3000";
|
|
32
|
+
const protocol = headersList.get("x-forwarded-proto") || "http";
|
|
33
|
+
const baseUrl = `${protocol}://${host}`;
|
|
34
|
+
|
|
35
|
+
// クッキーを取得して転送
|
|
36
|
+
const cookieStore = await cookies();
|
|
37
|
+
const cookieHeader = cookieStore
|
|
38
|
+
.getAll()
|
|
39
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
40
|
+
.join("; ");
|
|
41
|
+
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
if (filters.page) params.set("page", String(filters.page));
|
|
44
|
+
if (filters.limit) params.set("limit", String(filters.limit));
|
|
45
|
+
if (filters.search) params.set("search", filters.search);
|
|
46
|
+
if (filters.status) params.set("status", filters.status);
|
|
47
|
+
if (filters.role) params.set("role", filters.role);
|
|
48
|
+
|
|
49
|
+
const url = `${baseUrl}/api/rpc/users?${params.toString()}`;
|
|
50
|
+
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
headers: {
|
|
53
|
+
Cookie: cookieHeader,
|
|
54
|
+
},
|
|
55
|
+
cache: "no-store",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return parseResponse(response, paginatedUserListSchema);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error("Failed to prefetch users:", error);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
"use client";
|
|
8
8
|
|
|
9
|
+
import { apiClient } from "@/lib/api/client";
|
|
10
|
+
import { parseResponse } from "@/lib/api/parse-response";
|
|
11
|
+
import {
|
|
12
|
+
type PaginatedUserList,
|
|
13
|
+
type UserListItem,
|
|
14
|
+
paginatedUserListSchema,
|
|
15
|
+
userListItemSchema,
|
|
16
|
+
} from "@/shared/schemas/user";
|
|
9
17
|
import { useQuery } from "@tanstack/react-query";
|
|
10
|
-
import { apiClient } from "@/lib/api-client";
|
|
11
|
-
import type { PaginatedUserList } from "@web/application/use-cases/UserUseCases";
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* QueryKey factory for users
|
|
@@ -37,10 +43,7 @@ export interface UserFilters {
|
|
|
37
43
|
*
|
|
38
44
|
* ユーザー一覧を取得するhook
|
|
39
45
|
*/
|
|
40
|
-
export function useUsers(
|
|
41
|
-
filters: UserFilters = {},
|
|
42
|
-
initialData?: PaginatedUserList
|
|
43
|
-
) {
|
|
46
|
+
export function useUsers(filters: UserFilters = {}, initialData?: PaginatedUserList) {
|
|
44
47
|
return useQuery({
|
|
45
48
|
queryKey: userKeys.list(filters),
|
|
46
49
|
queryFn: async () => {
|
|
@@ -54,11 +57,7 @@ export function useUsers(
|
|
|
54
57
|
},
|
|
55
58
|
});
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
throw new Error("Failed to fetch users");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return response.json() as Promise<PaginatedUserList>;
|
|
60
|
+
return parseResponse(response, paginatedUserListSchema);
|
|
62
61
|
},
|
|
63
62
|
initialData,
|
|
64
63
|
});
|
|
@@ -77,42 +76,8 @@ export function useUser(id: string) {
|
|
|
77
76
|
param: { id },
|
|
78
77
|
});
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
throw new Error("Failed to fetch user");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return response.json();
|
|
79
|
+
return parseResponse(response, userListItemSchema);
|
|
85
80
|
},
|
|
86
81
|
enabled: !!id,
|
|
87
82
|
});
|
|
88
83
|
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* prefetchUsers
|
|
92
|
-
*
|
|
93
|
-
* Server Component用のデータ取得関数
|
|
94
|
-
*/
|
|
95
|
-
export async function prefetchUsers(
|
|
96
|
-
filters: UserFilters = {}
|
|
97
|
-
): Promise<PaginatedUserList> {
|
|
98
|
-
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
|
99
|
-
const params = new URLSearchParams();
|
|
100
|
-
|
|
101
|
-
if (filters.page) params.set("page", filters.page.toString());
|
|
102
|
-
if (filters.limit) params.set("limit", filters.limit.toString());
|
|
103
|
-
if (filters.search) params.set("search", filters.search);
|
|
104
|
-
if (filters.status) params.set("status", filters.status);
|
|
105
|
-
if (filters.role) params.set("role", filters.role);
|
|
106
|
-
|
|
107
|
-
const url = `${baseUrl}/api/rpc/users?${params.toString()}`;
|
|
108
|
-
|
|
109
|
-
const response = await fetch(url, {
|
|
110
|
-
cache: "no-store",
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
if (!response.ok) {
|
|
114
|
-
throw new Error("Failed to prefetch users");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return response.json();
|
|
118
|
-
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse-response.ts
|
|
3
|
+
*
|
|
4
|
+
* Zodスキーマによるレスポンス検証とエラーハンドリング
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* APIエラークラス
|
|
11
|
+
*
|
|
12
|
+
* APIレスポンスのエラーを表現する
|
|
13
|
+
*/
|
|
14
|
+
export class ApiError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly code: string,
|
|
17
|
+
message: string,
|
|
18
|
+
public readonly statusCode: number,
|
|
19
|
+
public readonly details?: Record<string, unknown>
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "ApiError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* JSONを安全にパースする
|
|
28
|
+
*
|
|
29
|
+
* @param response - Fetchレスポンス
|
|
30
|
+
* @returns パース結果(失敗時は空オブジェクト)
|
|
31
|
+
*/
|
|
32
|
+
async function safeJsonParse(response: Response): Promise<unknown> {
|
|
33
|
+
try {
|
|
34
|
+
return await response.json();
|
|
35
|
+
} catch {
|
|
36
|
+
// JSONパースに失敗した場合は空オブジェクトを返す
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* エラーレスポンスからエラー情報を抽出する
|
|
43
|
+
*
|
|
44
|
+
* バックエンドの形式: { error: string } または { error: { code, message, details } }
|
|
45
|
+
*
|
|
46
|
+
* @param json - パースされたJSON
|
|
47
|
+
* @param statusCode - HTTPステータスコード
|
|
48
|
+
* @returns エラー情報
|
|
49
|
+
*/
|
|
50
|
+
function extractErrorInfo(
|
|
51
|
+
json: unknown,
|
|
52
|
+
statusCode: number
|
|
53
|
+
): { code: string; message: string; details?: Record<string, unknown> } {
|
|
54
|
+
if (typeof json === "object" && json !== null && "error" in json) {
|
|
55
|
+
const errorField = (json as { error: unknown }).error;
|
|
56
|
+
|
|
57
|
+
// { error: string } 形式
|
|
58
|
+
if (typeof errorField === "string") {
|
|
59
|
+
return {
|
|
60
|
+
code: "API_ERROR",
|
|
61
|
+
message: errorField,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// { error: { code, message, details } } 形式
|
|
66
|
+
if (typeof errorField === "object" && errorField !== null) {
|
|
67
|
+
const errObj = errorField as {
|
|
68
|
+
code?: string;
|
|
69
|
+
message?: string;
|
|
70
|
+
details?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
code: errObj.code ?? "API_ERROR",
|
|
74
|
+
message: errObj.message ?? "An unknown error occurred",
|
|
75
|
+
details: errObj.details,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
code: "UNKNOWN_ERROR",
|
|
82
|
+
message: "An unknown error occurred",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* レスポンスをパースしてZodスキーマで検証
|
|
88
|
+
*
|
|
89
|
+
* @param response - Fetchレスポンス
|
|
90
|
+
* @param schema - Zodスキーマ
|
|
91
|
+
* @returns バリデーション済みのデータ
|
|
92
|
+
* @throws ApiError - レスポンスエラーまたはバリデーションエラー
|
|
93
|
+
*/
|
|
94
|
+
export async function parseResponse<T>(response: Response, schema: z.ZodType<T>): Promise<T> {
|
|
95
|
+
// エラーレスポンスの場合
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const json = await safeJsonParse(response);
|
|
98
|
+
const errorInfo = extractErrorInfo(json, response.status);
|
|
99
|
+
throw new ApiError(errorInfo.code, errorInfo.message, response.status, errorInfo.details);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 成功レスポンスの場合
|
|
103
|
+
const json = await safeJsonParse(response);
|
|
104
|
+
|
|
105
|
+
// Zodスキーマでバリデーション
|
|
106
|
+
const result = schema.safeParse(json);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
throw new ApiError("VALIDATION_ERROR", "Response validation failed", response.status, {
|
|
109
|
+
zodErrors: result.error.flatten(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result.data;
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user.ts
|
|
3
|
+
*
|
|
4
|
+
* ユーザー関連のZodスキーマと型定義
|
|
5
|
+
* APIレスポンスの型検証に使用
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ユーザー一覧アイテムのレスポンススキーマ
|
|
12
|
+
* APIレスポンスの型検証に使用
|
|
13
|
+
*/
|
|
14
|
+
export const userListItemSchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
name: z.string(),
|
|
17
|
+
email: z.string(),
|
|
18
|
+
status: z.enum(["active", "inactive", "pending"]),
|
|
19
|
+
role: z.enum(["admin", "user", "moderator"]),
|
|
20
|
+
createdAt: z.string(),
|
|
21
|
+
lastLogin: z.string().nullable(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ページネーション付きユーザー一覧のレスポンススキーマ
|
|
26
|
+
*/
|
|
27
|
+
export const paginatedUserListSchema = z.object({
|
|
28
|
+
items: z.array(userListItemSchema),
|
|
29
|
+
total: z.number(),
|
|
30
|
+
page: z.number(),
|
|
31
|
+
limit: z.number(),
|
|
32
|
+
totalPages: z.number(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type UserListItem = z.infer<typeof userListItemSchema>;
|
|
36
|
+
export type PaginatedUserList = z.infer<typeof paginatedUserListSchema>;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { auth } from "@/lib/auth";
|
|
2
|
-
|
|
3
|
-
export default auth((req) => {
|
|
4
|
-
const { nextUrl } = req;
|
|
5
|
-
const isLoggedIn = !!req.auth;
|
|
6
|
-
|
|
7
|
-
// Protected routes that require authentication
|
|
8
|
-
const protectedRoutes = ["/dashboard", "/profile"];
|
|
9
|
-
const isProtectedRoute = protectedRoutes.some((route) =>
|
|
10
|
-
nextUrl.pathname.startsWith(route),
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
// Public routes that authenticated users shouldn't access
|
|
14
|
-
const publicRoutes = ["/signin", "/signup"];
|
|
15
|
-
const isPublicRoute = publicRoutes.some((route) =>
|
|
16
|
-
nextUrl.pathname.startsWith(route),
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
// Redirect authenticated users away from public routes to dashboard
|
|
20
|
-
if (isLoggedIn && isPublicRoute) {
|
|
21
|
-
return Response.redirect(new URL("/dashboard", nextUrl));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Redirect unauthenticated users to signin
|
|
25
|
-
if (!isLoggedIn && isProtectedRoute) {
|
|
26
|
-
return Response.redirect(new URL("/signin", nextUrl));
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const config = {
|
|
31
|
-
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
|
32
|
-
};
|