diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ee48b77..a634c61 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" - run: npm ci - run: npx biome ci client-build: @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" - run: npm ci - run: cd client && bash build.sh server-build: @@ -33,6 +33,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" - run: npm ci - run: cd server && npm run build + - run: cd server && npm run typecheck diff --git a/Dockerfile b/Dockerfile index ca9c6f3..e5e6a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20 AS builder +FROM node:24 AS builder WORKDIR /app COPY package*.json ./ @@ -10,7 +10,7 @@ COPY common ./common COPY server ./server RUN npm run build:server -FROM node:20-slim +FROM node:24-slim WORKDIR /app RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 88cbe76..52700f3 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -7,7 +7,7 @@ import Footer from "../components/Footer"; import Header from "../components/Header"; import { briefProjectReviver } from "../revivers"; import type { BriefProject } from "../types"; -import { API_ENDPOINT } from "../utils"; +import { API_ENDPOINT, readErrorMessage } from "../utils"; const client = hc(API_ENDPOINT); @@ -29,16 +29,7 @@ export default function HomePage() { const parsedData = data.map((p) => briefProjectReviver(p)); setInvolvedProjects(parsedData); } else { - let errorMessage = "イベントの取得に失敗しました。"; - try { - const data = await res.json(); - const err = data as unknown as { message: string }; // Middleware のレスポンスは Hono RPC の型に乗らない - if (typeof err.message === "string" && err.message.trim()) { - errorMessage = err.message.trim(); - } - } catch (_) { - // レスポンスがJSONでない場合は無視 - } + const errorMessage = await readErrorMessage(res, "イベントの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 4f236fb..498d491 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -24,7 +24,7 @@ import { EXTERNAL_LINKS } from "../constants/links"; import dayjs from "../lib/dayjs"; import { projectReviver } from "../revivers"; import type { Project } from "../types"; -import { API_ENDPOINT, FRONTEND_ORIGIN } from "../utils"; +import { API_ENDPOINT, FRONTEND_ORIGIN, readErrorMessage } from "../utils"; const client = hc(API_ENDPOINT); @@ -59,15 +59,7 @@ export default function ProjectPage() { const parsedData = projectReviver(data); setProject(parsedData); } else { - let errorMessage = "プロジェクトの取得に失敗しました。"; - try { - const data = await res.json(); - if (data && typeof data.message === "string" && data.message.trim()) { - errorMessage = data.message.trim(); - } - } catch (_) { - // レスポンスがJSONでない場合は無視 - } + const errorMessage = await readErrorMessage(res, "プロジェクトの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", @@ -254,17 +246,10 @@ export default function ProjectPage() { }); setTimeout(() => setToast(null), 3000); } else { - let errorMessage = "更新に失敗しました。"; - try { - const data = await res.json(); - if (data && typeof data.message === "string" && data.message.trim()) { - errorMessage = data.message.trim(); - } else if (res.status === 403) { - errorMessage = "権限がありません。"; - } - } catch (_) { - if (res.status === 403) errorMessage = "権限がありません。"; - } + const errorMessage = await readErrorMessage( + res, + (res.status as number) === 403 ? "権限がありません。" : "更新に失敗しました。", + ); setToast({ message: errorMessage, variant: "error", diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index c78f267..ae99d81 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -21,7 +21,7 @@ import { Calendar, type Highlight } from "../../components/Calendar"; import Header from "../../components/Header"; import { projectReviver } from "../../revivers"; import type { Project, Slot } from "../../types"; -import { API_ENDPOINT } from "../../utils"; +import { API_ENDPOINT, readErrorMessage } from "../../utils"; const client = hc(API_ENDPOINT); @@ -103,15 +103,7 @@ export default function SubmissionPage() { const parsedData = projectReviver(data); setProject(parsedData); } else { - let errorMessage = "プロジェクトの取得に失敗しました。"; - try { - const data = await res.json(); - if (data && typeof data.message === "string" && data.message.trim()) { - errorMessage = data.message.trim(); - } - } catch (_) { - // レスポンスがJSONでない場合は無視 - } + const errorMessage = await readErrorMessage(res, "プロジェクトの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", diff --git a/client/src/utils.ts b/client/src/utils.ts index f128965..1b3a1d4 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -1,2 +1,18 @@ export const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT || "http://localhost:3000"; export const FRONTEND_ORIGIN = import.meta.env.VITE_FRONTEND_ORIGIN || "http://localhost:5173"; + +/** + * エラーレスポンスの `{ message }` を読む。読めなければ `fallback` を返す。 + * サーバーの onError やミドルウェアが返すエラーは Hono RPC の型に乗らないので、型を付けずに読む。 + */ +export async function readErrorMessage(res: { json(): Promise }, fallback: string): Promise { + try { + const data = await res.json(); + if (data && typeof data === "object" && "message" in data && typeof data.message === "string") { + return data.message.trim() || fallback; + } + } catch (_) { + // レスポンスがJSONでない場合は無視 + } + return fallback; +} diff --git a/package-lock.json b/package-lock.json index d40398c..c5c2180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -241,23 +241,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { @@ -318,16 +318,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", + "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" @@ -1170,9 +1170,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", + "integrity": "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==", "cpu": [ "arm" ], @@ -1183,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", + "integrity": "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==", "cpu": [ "arm64" ], @@ -1196,9 +1196,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", + "integrity": "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==", "cpu": [ "arm64" ], @@ -1209,9 +1209,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", + "integrity": "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==", "cpu": [ "x64" ], @@ -1222,9 +1222,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", + "integrity": "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==", "cpu": [ "arm64" ], @@ -1235,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", + "integrity": "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==", "cpu": [ "x64" ], @@ -1248,9 +1248,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", + "integrity": "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==", "cpu": [ "arm" ], @@ -1264,9 +1264,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", + "integrity": "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==", "cpu": [ "arm" ], @@ -1280,9 +1280,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", + "integrity": "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==", "cpu": [ "arm64" ], @@ -1296,9 +1296,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", + "integrity": "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==", "cpu": [ "arm64" ], @@ -1312,9 +1312,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", + "integrity": "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==", "cpu": [ "loong64" ], @@ -1328,9 +1328,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", + "integrity": "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==", "cpu": [ "loong64" ], @@ -1344,9 +1344,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", + "integrity": "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==", "cpu": [ "ppc64" ], @@ -1360,9 +1360,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", + "integrity": "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==", "cpu": [ "ppc64" ], @@ -1376,9 +1376,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", + "integrity": "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==", "cpu": [ "riscv64" ], @@ -1392,9 +1392,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", + "integrity": "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==", "cpu": [ "riscv64" ], @@ -1408,9 +1408,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", + "integrity": "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==", "cpu": [ "s390x" ], @@ -1424,9 +1424,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", - "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", + "integrity": "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==", "cpu": [ "x64" ], @@ -1440,9 +1440,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", + "integrity": "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==", "cpu": [ "x64" ], @@ -1456,9 +1456,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", + "integrity": "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==", "cpu": [ "x64" ], @@ -1469,9 +1469,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", + "integrity": "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==", "cpu": [ "arm64" ], @@ -1482,9 +1482,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", + "integrity": "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==", "cpu": [ "arm64" ], @@ -1495,9 +1495,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", + "integrity": "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==", "cpu": [ "ia32" ], @@ -1508,9 +1508,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", + "integrity": "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==", "cpu": [ "x64" ], @@ -1521,9 +1521,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", + "integrity": "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==", "cpu": [ "x64" ], @@ -1860,6 +1860,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1867,33 +1885,33 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@vitejs/plugin-react": { @@ -1917,10 +1935,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { - "version": "2.11.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", - "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "version": "2.11.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.30.tgz", + "integrity": "sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1931,9 +1969,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "dev": true, "funding": [ { @@ -1951,11 +1989,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.20", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -1994,9 +2032,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "dev": true, "funding": [ { @@ -2014,6 +2052,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2095,13 +2143,13 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/daisyui": { - "version": "5.7.28", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.28.tgz", - "integrity": "sha512-BxQap2QOj7NQy5nEsjTHRmybmUZP3t+O5G6vfdidZuptBsh/QyKN9wbdrGDZk198fMI3UyYnA/rCWlggZIhGnw==", + "version": "5.7.50", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.50.tgz", + "integrity": "sha512-+/LRqnTCoc7s/bJEKKwtJSwzeXCVdAmRdNmZeZzgHCaQ9PJyl7j+2V2U32GQDMyD+VDkP9e/+JGoC72Y1hLk7w==", "dev": true, "license": "MIT", "funding": { @@ -2189,9 +2237,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.423", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", - "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", + "version": "1.5.463", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", + "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", "dev": true, "license": "ISC" }, @@ -2206,9 +2254,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2218,6 +2266,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -2270,10 +2325,30 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "devOptional": true, "license": "MIT" }, @@ -2366,18 +2441,18 @@ "license": "ISC" }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" } }, "node_modules/ics": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/ics/-/ics-3.12.0.tgz", - "integrity": "sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==", + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/ics/-/ics-3.12.1.tgz", + "integrity": "sha512-Ju0prGo1DoVNEqHnNQqlSnWZBxoqjZ4wIlNxfzNsEnCXP3IEPRKsERVyoZpQeFej6EN8BvMgfHRuzc2fqnGf2A==", "license": "ISC", "dependencies": { "nanoid": "^3.1.23", @@ -2386,9 +2461,9 @@ } }, "node_modules/ics/node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2758,9 +2833,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { @@ -2768,15 +2843,15 @@ } }, "node_modules/nypm": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", - "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.11.tgz", + "integrity": "sha512-wrVBTLWDu7nTUKGWplQebWOXWnhaIwyKQhBxobH/CGxJHoVw27r9vG5ba7kdZnxG+f4rC6dZd6m2oHmpkOcO7g==", "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", - "tinyexec": "^1.2.4" + "tinyexec": "^1.3.1" }, "bin": { "nypm": "dist/cli.mjs" @@ -2792,6 +2867,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", @@ -2832,14 +2921,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", - "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", - "exsolve": "^1.1.1", + "exsolve": "^1.1.3", "pathe": "^2.0.3" } }, @@ -2851,9 +2940,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -2870,18 +2959,18 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2957,30 +3046,30 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.1" } }, "node_modules/react-hook-form": { - "version": "7.87.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.87.0.tgz", - "integrity": "sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==", + "version": "7.90.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.90.0.tgz", + "integrity": "sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2990,7 +3079,13 @@ "url": "https://opencollective.com/react-hook-form" }, "peerDependencies": { + "@types/react": "*", "react": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-icons": { @@ -3013,9 +3108,9 @@ } }, "node_modules/react-router": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", - "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3063,9 +3158,9 @@ } }, "node_modules/rollup": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", - "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", + "integrity": "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==", "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -3079,44 +3174,44 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", + "@rollup/rollup-android-arm-eabi": "4.64.12", + "@rollup/rollup-android-arm64": "4.64.12", + "@rollup/rollup-darwin-arm64": "4.64.12", + "@rollup/rollup-darwin-x64": "4.64.12", + "@rollup/rollup-freebsd-arm64": "4.64.12", + "@rollup/rollup-freebsd-x64": "4.64.12", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", + "@rollup/rollup-linux-arm-musleabihf": "4.64.12", + "@rollup/rollup-linux-arm64-gnu": "4.64.12", + "@rollup/rollup-linux-arm64-musl": "4.64.12", + "@rollup/rollup-linux-loong64-gnu": "4.64.12", + "@rollup/rollup-linux-loong64-musl": "4.64.12", + "@rollup/rollup-linux-ppc64-gnu": "4.64.12", + "@rollup/rollup-linux-ppc64-musl": "4.64.12", + "@rollup/rollup-linux-riscv64-gnu": "4.64.12", + "@rollup/rollup-linux-riscv64-musl": "4.64.12", + "@rollup/rollup-linux-s390x-gnu": "4.64.12", + "@rollup/rollup-linux-x64-gnu": "4.64.12", + "@rollup/rollup-linux-x64-musl": "4.64.12", + "@rollup/rollup-openbsd-x64": "4.64.12", + "@rollup/rollup-openharmony-arm64": "4.64.12", + "@rollup/rollup-win32-arm64-msvc": "4.64.12", + "@rollup/rollup-win32-ia32-msvc": "4.64.12", + "@rollup/rollup-win32-x64-gnu": "4.64.12", + "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" } }, "node_modules/runes2": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", - "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.10.tgz", + "integrity": "sha512-4Y1en7X+3i0pf9NDPMEerT1CkZlExpQJPnEPlrBXzXSIHO3etyZR6Wbpl+hHWVYRFnhPVCyPHaJks6GcCFgEiA==", "license": "MIT" }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -3140,14 +3235,21 @@ "license": "MIT" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3173,6 +3275,16 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyexec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -3206,9 +3318,9 @@ "license": "MIT" }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3251,16 +3363,16 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", - "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -3289,9 +3401,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -3819,6 +3931,140 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3860,10 +4106,11 @@ "zod": "^3.24.2" }, "devDependencies": { - "@types/node": "^22.13.10", + "@types/node": "^24.19.2", "prisma": "^6.5.0", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^5.0.5" } }, "server/node_modules/typescript": { diff --git a/server/.env.sample b/server/.env.sample index 67c5cde..f18140c 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -1,5 +1,7 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/itsuhima_dev +PORT=3000 CORS_ALLOW_ORIGINS=http://localhost:5173 -DOMAIN=localhost +DOMAIN=localhost # browserId の Cookie を送るドメイン NODE_ENV=dev # dev or production -COOKIE_SECRET=your-random-secret-key-for-development +COOKIE_SECRET=your-random-secret-key-for-development # Cookie の署名鍵 +# COOKIE_SECRET_PREVIOUS= # 鍵のローテーション時、前の COOKIE_SECRET をカンマ区切りで指定する。省略可 diff --git a/server/package.json b/server/package.json index 509717e..fd4d13e 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "tsx watch src/main.ts", "prebuild": "prisma generate", - "build": "tsc" + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc", + "test": "vitest run" }, "keywords": [], "author": "", @@ -22,9 +24,10 @@ "zod": "^3.24.2" }, "devDependencies": { - "@types/node": "^22.13.10", + "@types/node": "^24.19.2", "prisma": "^6.5.0", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^5.0.5" } } diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..44411b8 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { browserIdMiddleware } from "./middleware/browserId.js"; +import projectsRoutes from "./routes/projects.js"; +import { ServiceError, type ServiceErrorReason } from "./services/errors.js"; + +const allowedOrigins = process.env.CORS_ALLOW_ORIGINS?.split(",") || []; + +const serviceErrorStatus = { + project_not_found: 404, + submission_not_found: 404, + not_host: 403, + already_submitted: 409, + options_empty: 400, + option_in_use: 400, + invalid_slot: 400, +} as const satisfies Record; + +export type AppVariables = { + browserId: string; +}; + +export const app = new Hono<{ Variables: AppVariables }>() + .use( + "*", + cors({ + origin: allowedOrigins, + credentials: true, + }), + ) + .use("*", browserIdMiddleware) + .get("/", (c) => { + return c.json({ message: "Hello! イツヒマ?" }); + }) + .route("/projects", projectsRoutes) + .onError((err, c) => { + if (err instanceof ServiceError) { + return c.json({ message: err.message }, serviceErrorStatus[err.reason]); + } + console.error(err); + return c.json({ message: "Internal Server Error" }, 500); + }); + +export type AppType = typeof app; diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..1be5185 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,10 @@ +const isProduction = process.env.NODE_ENV === "production"; + +export const cookieOptions = { + path: "/", + domain: process.env.DOMAIN || "localhost", // /home へのリダイレクトのためフロントエンドにも送る + httpOnly: true, + secure: isProduction, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, // Express だとミリ秒だったが、Hono では秒らしい +} as const; diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/server/src/lib/id.ts b/server/src/lib/id.ts new file mode 100644 index 0000000..2c7f356 --- /dev/null +++ b/server/src/lib/id.ts @@ -0,0 +1,6 @@ +import { customAlphabet } from "nanoid"; + +/** + * ハイフン・アンダースコアを含まない Nano ID 形式。 + */ +export const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 21); diff --git a/server/src/main.ts b/server/src/main.ts index 69f937f..242d30c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,45 +1,10 @@ +import "dotenv/config"; import { serve } from "@hono/node-server"; -import { PrismaClient } from "@prisma/client"; -import dotenv from "dotenv"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { customAlphabet } from "nanoid"; -import { browserIdMiddleware } from "./middleware/browserId.js"; -import projectsRoutes from "./routes/projects.js"; +import { app } from "./app.js"; -dotenv.config(); - -/** - * ハイフン・アンダースコアを含まない Nano ID 形式。 - */ -export const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 21); - -export const prisma = new PrismaClient(); +export type { AppType } from "./app.js"; const port = Number(process.env.PORT) || 3000; -const allowedOrigins = process.env.CORS_ALLOW_ORIGINS?.split(",") || []; - -export type AppVariables = { - browserId: string; -}; - -const app = new Hono<{ Variables: AppVariables }>() - .use( - "*", - cors({ - origin: allowedOrigins, - credentials: true, - }), - ) - .use("*", browserIdMiddleware) - .get("/", (c) => { - return c.json({ message: "Hello! イツヒマ?" }); - }) - .route("/projects", projectsRoutes) - .onError((err, c) => { - console.error(err); - return c.json({ message: "Internal Server Error" }, 500); - }); serve( { @@ -51,16 +16,3 @@ serve( console.log(`Server listening on 0.0.0.0:${port}`); }, ); - -const isProduction = process.env.NODE_ENV === "production"; - -export const cookieOptions = { - path: "/", - domain: process.env.DOMAIN || "localhost", // /home へのリダイレクトのためフロントエンドにも送る - httpOnly: true, - secure: isProduction, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 365, // Express だとミリ秒だったが、Hono では秒らしい -} as const; - -export type AppType = typeof app; diff --git a/server/src/middleware/browserId.ts b/server/src/middleware/browserId.ts index 06d7101..8e303b7 100644 --- a/server/src/middleware/browserId.ts +++ b/server/src/middleware/browserId.ts @@ -1,75 +1,56 @@ -import crypto from "node:crypto"; import type { Context, MiddlewareHandler } from "hono"; import { getCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; -import { cookieOptions } from "../main.js"; +import { cookieOptions } from "../config.js"; const COOKIE_NAME = "browserId"; /** - * Express の署名付きクッキー(s:value.signature 形式)を検証して値を取り出す + * 署名鍵のローテーションに対応するため、現行の鍵、前の鍵の順に検証する。 + * @returns 検証できた browserId。Cookie が無ければ undefined。Cookie はあるがどの鍵でも検証できなければ false */ -function unsignExpressCookie(signedValue: string, secret: string): string | null { - if (!signedValue.startsWith("s:")) return null; - - const raw = signedValue.slice(2); // `s:` を削除 - const lastDotIndex = raw.lastIndexOf("."); - if (lastDotIndex === -1) return null; - - const value = raw.slice(0, lastDotIndex); // 署名前の値(uuid) - const signature = raw.slice(lastDotIndex + 1); - - // Express と同じアルゴリズムで署名を検証 - const expectedSignature = crypto.createHmac("sha256", secret).update(value).digest("base64").replace(/=+$/, ""); - - if (signature === expectedSignature) { - return value; +async function readBrowserId(c: Context, secrets: string[]) { + if (getCookie(c, COOKIE_NAME) === undefined) { + return undefined; } - return null; + for (const secret of secrets) { + const value = await getSignedCookie(c, secret, COOKIE_NAME); + if (value) { + return value; + } + } + return false; } -/** - * Express → Hono の移行で signed cookie 形式が変わったため両方に対応するミドルウェア - */ export const browserIdMiddleware: MiddlewareHandler = async (c: Context, next) => { const cookieSecret = process.env.COOKIE_SECRET; if (!cookieSecret) { console.error("COOKIE_SECRET is not set"); return c.json({ message: "サーバー設定エラー" }, 500); } + const previousSecrets = (process.env.COOKIE_SECRET_PREVIOUS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); - // 新形式 (Hono) を試す - const browserIdHono = (await getSignedCookie(c, cookieSecret, COOKIE_NAME)) || undefined; - if (browserIdHono) { - // Cookie を更新 - await setSignedCookie(c, COOKIE_NAME, browserIdHono, cookieSecret, cookieOptions); - c.set("browserId", browserIdHono); - return next(); - } + const browserId = await readBrowserId(c, [cookieSecret, ...previousSecrets]); - // "browserId" という Cookie が存在しない場合は新規発行 - const rawCookie = getCookie(c, COOKIE_NAME); - if (!rawCookie) { - const browserId = crypto.randomUUID(); - await setSignedCookie(c, COOKIE_NAME, browserId, cookieSecret, cookieOptions); - c.set("browserId", browserId); - return next(); + // Cookie はあるがどの鍵でも検証できなかった + if (browserId === false) { + console.warn("browserId Cookie の署名を検証できませんでした"); + return c.json({ message: "ブラウザのCookie設定に問題があります。" }, 400); } - // 旧形式(Express)を試す - const browserIdExpress = unsignExpressCookie(rawCookie, cookieSecret); - if (browserIdExpress) { - // 旧形式が有効な場合は新形式で再発行 - await setSignedCookie(c, COOKIE_NAME, browserIdExpress, cookieSecret, cookieOptions); - c.set("browserId", browserIdExpress); + // 初回アクセスでの新規発行 + if (browserId === undefined) { + const newBrowserId = crypto.randomUUID(); + await setSignedCookie(c, COOKIE_NAME, newBrowserId, cookieSecret, cookieOptions); + c.set("browserId", newBrowserId); return next(); } - // ここまで来たら Cookie が不正 - return c.json( - { - message: "ブラウザのCookie設定に問題があります。", - }, - 400, - ); + // 2回目以降のアクセスでの再発行 (新しい鍵で署名・有効期限の更新) + await setSignedCookie(c, COOKIE_NAME, browserId, cookieSecret, cookieOptions); + c.set("browserId", browserId); + return next(); }; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index d4b0bc7..621eb6e 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,248 +1,51 @@ import { zValidator } from "@hono/zod-validator"; -import dotenv from "dotenv"; import { Hono } from "hono"; import { z } from "zod"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; -import { type AppVariables, nanoid, prisma } from "../main.js"; - -dotenv.config(); +import type { AppVariables } from "../app.js"; +import { + createProject, + deleteProject, + getProject, + listMyProjects, + submitAvailability, + updateMyAvailability, + updateProject, +} from "../services/projects.js"; const projectIdParamsSchema = z.object({ projectId: z.string().length(21) }); const router = new Hono<{ Variables: AppVariables }>() // プロジェクト作成 .post("/", zValidator("json", projectReqSchema), async (c) => { - const browserId = c.get("browserId"); - const input = c.req.valid("json"); - - const project = await prisma.project.create({ - data: { - id: nanoid(), - name: input.name, - description: input.description.trim() || null, - startDate: new Date(input.startDate), - endDate: new Date(input.endDate), - allowedRanges: { - create: input.allowedRanges.map((range) => ({ - startTime: new Date(range.startTime), - endTime: new Date(range.endTime), - })), - }, - hosts: { - create: { - browserId, - }, - }, - participationOptions: { - create: input.participationOptions.map((opt) => ({ - id: opt.id, - label: opt.label, - color: opt.color, - })), - }, - }, - select: { - id: true, - name: true, - }, - }); - + const project = await createProject(c.get("browserId"), c.req.valid("json")); return c.json({ id: project.id, name: project.name }, 201); }) // 自分が関連するプロジェクト取得 .get("/mine", async (c) => { - const browserId = c.get("browserId"); - - const projects = await prisma.project.findMany({ - where: { - OR: [ - { hosts: { some: { browserId } } }, - { - guests: { - some: { browserId }, - }, - }, - ], - }, - include: { - hosts: { - select: { browserId: true }, - }, - }, - }); - - return c.json( - projects.map((p) => ({ - id: p.id, - name: p.name, - description: p.description ?? "", - startDate: p.startDate, - endDate: p.endDate, - isHost: p.hosts.some((host) => host.browserId === browserId), - })), - 200, - ); + const projects = await listMyProjects(c.get("browserId")); + return c.json(projects, 200); }) // プロジェクト取得 .get("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { - const browserId = c.get("browserId"); const { projectId } = c.req.valid("param"); - - const project = await prisma.project.findUnique({ - where: { id: projectId }, - include: { - allowedRanges: true, - participationOptions: true, - guests: { - include: { - slots: true, - }, - }, - hosts: true, - }, - }); - - if (!project) { - return c.json({ message: "イベントが見つかりません。" }, 404); - } - - const guest = project.guests.find((g) => g.browserId === browserId); - const meAsGuest = guest ? (({ browserId, ...rest }) => rest)(guest) : null; - - return c.json( - { - id: project.id, - name: project.name, - description: project.description ?? "", - startDate: project.startDate, - endDate: project.endDate, - allowedRanges: project.allowedRanges, - participationOptions: project.participationOptions, - hosts: project.hosts.map(({ browserId, ...rest }) => rest), - guests: project.guests.map(({ browserId, ...rest }) => rest), - isHost: project.hosts.some((h) => h.browserId === browserId), - meAsGuest, - }, - 200, - ); + const project = await getProject(c.get("browserId"), projectId); + return c.json(project, 200); }) // プロジェクト編集 .put("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { - const browserId = c.get("browserId"); const { projectId } = c.req.valid("param"); - const input = c.req.valid("json"); - - const [host, existingGuest] = await Promise.all([ - prisma.host.findFirst({ - where: { - browserId, - projectId: projectId, - }, - }), - prisma.guest.findFirst({ - where: { projectId: projectId }, - }), - ]); - - if (!host) { - return c.json({ message: "アクセス権限がありません。" }, 403); - } - - // 参加形態の更新 - if (input.participationOptions) { - if (input.participationOptions.length === 0) { - return c.json({ message: "参加形態は最低1つ必要です。" }, 400); - } - - // 削除対象の参加形態に Slot が紐づいているかチェック - const existingOptions = await prisma.participationOption.findMany({ - where: { projectId }, - include: { slots: { select: { id: true } } }, - }); - const newOptionIds = input.participationOptions.map((o) => o.id); - const optionsToDelete = existingOptions.filter((o) => !newOptionIds.includes(o.id)); - const undeletableOptions = optionsToDelete.filter((o) => o.slots.length > 0); - if (undeletableOptions.length > 0) { - const labels = undeletableOptions.map((o) => o.label).join(", "); - return c.json( - { - message: `以下の参加形態は日程が登録されているため削除できません: ${labels}`, - }, - 400, - ); - } - - await prisma.$transaction([ - // 既存の参加形態で、新しいリストにないものを削除 - prisma.participationOption.deleteMany({ - where: { - projectId, - id: { - notIn: newOptionIds, - }, - }, - }), - // 既存の参加形態を更新または新規作成 - ...input.participationOptions.map((opt) => - prisma.participationOption.upsert({ - where: { id: opt.id }, - update: { label: opt.label, color: opt.color }, - create: { id: opt.id, label: opt.label, color: opt.color, projectId }, - }), - ), - ]); - } - - const updatedProject = await prisma.project.update({ - where: { id: projectId }, - data: existingGuest - ? { - name: input.name, - description: input.description?.trim() || null, - } - : { - name: input.name, - description: input.description?.trim() || null, - startDate: input.startDate ? new Date(input.startDate) : undefined, - endDate: input.endDate ? new Date(input.endDate) : undefined, - allowedRanges: { - deleteMany: {}, // 既存削除 - create: input.allowedRanges?.map((r) => ({ - startTime: new Date(r.startTime), - endTime: new Date(r.endTime), - })), - }, - }, - include: { allowedRanges: true, participationOptions: true }, - }); - - return c.json({ event: updatedProject }, 200); + const event = await updateProject(c.get("browserId"), projectId, c.req.valid("json")); + return c.json({ event }, 200); }) // プロジェクト削除 .delete("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { - const browserId = c.get("browserId"); const { projectId } = c.req.valid("param"); - - const host = await prisma.host.findUnique({ - where: { - browserId_projectId: { - browserId, - projectId, - }, - }, - }); - - if (!host) { - return c.json({ message: "削除権限がありません。" }, 403); - } - - await prisma.project.delete({ - where: { id: projectId }, - }); + await deleteProject(c.get("browserId"), projectId); return c.json(204); }) @@ -252,39 +55,9 @@ const router = new Hono<{ Variables: AppVariables }>() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const browserId = c.get("browserId"); const { projectId } = c.req.valid("param"); const { name, comment, slots } = c.req.valid("json"); - - const existingGuest = await prisma.guest.findUnique({ - where: { - browserId_projectId: { - browserId, - projectId, - }, - }, - }); - if (existingGuest) { - return c.json({ message: "提出済みです。" }, 403); - } - - await prisma.guest.create({ - data: { - name, - comment: comment?.trim() || null, - browserId, - project: { connect: { id: projectId } }, - slots: { - create: slots?.map((slot) => ({ - from: slot.start, - to: slot.end, - projectId, - participationOptionId: slot.participationOptionId, - })), - }, - }, - include: { slots: true }, - }); + await submitAvailability(c.get("browserId"), projectId, { name, comment, slots }); return c.json("日程が提出されました。", 201); }, ) @@ -295,37 +68,9 @@ const router = new Hono<{ Variables: AppVariables }>() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const browserId = c.get("browserId"); const { projectId } = c.req.valid("param"); const { name, comment, slots } = c.req.valid("json"); - - const existingGuest = await prisma.guest.findUnique({ - where: { browserId_projectId: { browserId, projectId } }, - include: { slots: true }, - }); - - if (!existingGuest) { - return c.json({ message: "既存の日程が見つかりません。" }, 404); - } - const slotData = slots?.map((slot) => ({ - from: slot.start, - to: slot.end, - projectId, - participationOptionId: slot.participationOptionId, - })); - - await prisma.slot.deleteMany({ where: { guestId: existingGuest.id } }); - - const guest = await prisma.guest.update({ - where: { id: existingGuest.id }, - data: { - slots: { create: slotData }, - name, - comment: comment?.trim() || null, - }, - include: { slots: true }, - }); - + const guest = await updateMyAvailability(c.get("browserId"), projectId, { name, comment, slots }); return c.json({ message: "日程が更新されました。", guest }, 200); }, ); diff --git a/server/src/services/errors.ts b/server/src/services/errors.ts new file mode 100644 index 0000000..8d22d5f --- /dev/null +++ b/server/src/services/errors.ts @@ -0,0 +1,25 @@ +/** + * サービス層のエラーの理由。HTTP のステータスへの変換は呼び出す側 (`app.ts` の `onError`) が行う。 + */ +export type ServiceErrorReason = + | "project_not_found" + | "submission_not_found" + | "not_host" + | "already_submitted" + | "options_empty" + | "option_in_use" + | "invalid_slot"; + +/** + * サービス層が投げる業務エラー。message は Web UI にそのまま表示する既定の文で、 + * 呼び出し元 (P2 の MCP など) が reason を見て差し替えてよい。 + */ +export class ServiceError extends Error { + constructor( + readonly reason: ServiceErrorReason, + message: string, + ) { + super(message); + this.name = "ServiceError"; + } +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts new file mode 100644 index 0000000..0095990 --- /dev/null +++ b/server/src/services/projects.ts @@ -0,0 +1,300 @@ +import type { z } from "zod"; +import type { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; +import { prisma } from "../db.js"; +import { nanoid } from "../lib/id.js"; +import { ServiceError } from "./errors.js"; +import { validateSlots } from "./slots.js"; + +export type CreateProjectInput = z.infer; +export type UpdateProjectInput = z.infer; +export type SubmissionInput = Omit, "projectId">; + +export async function createProject(browserId: string, input: CreateProjectInput) { + return prisma.project.create({ + data: { + id: nanoid(), + name: input.name, + description: input.description.trim() || null, + startDate: new Date(input.startDate), + endDate: new Date(input.endDate), + allowedRanges: { + create: input.allowedRanges.map((range) => ({ + startTime: new Date(range.startTime), + endTime: new Date(range.endTime), + })), + }, + hosts: { + create: { + browserId, + }, + }, + participationOptions: { + create: input.participationOptions.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + })), + }, + }, + select: { + id: true, + name: true, + }, + }); +} + +/** ホストまたはゲストとして関わるプロジェクト */ +export async function listMyProjects(browserId: string) { + const projects = await prisma.project.findMany({ + where: { + OR: [ + { hosts: { some: { browserId } } }, + { + guests: { + some: { browserId }, + }, + }, + ], + }, + include: { + hosts: { + select: { browserId: true }, + }, + }, + }); + + return projects.map((p) => ({ + id: p.id, + name: p.name, + description: p.description ?? "", + startDate: p.startDate, + endDate: p.endDate, + isHost: p.hosts.some((host) => host.browserId === browserId), + })); +} + +/** プロジェクトの詳細。URL (プロジェクト ID) を知っていれば誰でも見られる。 */ +export async function getProject(browserId: string, projectId: string) { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + allowedRanges: true, + participationOptions: true, + guests: { + include: { + slots: true, + }, + }, + hosts: true, + }, + }); + + if (!project) { + throw new ServiceError("project_not_found", "イベントが見つかりません。"); + } + + const guest = project.guests.find((g) => g.browserId === browserId); + + return { + id: project.id, + name: project.name, + description: project.description ?? "", + startDate: project.startDate, + endDate: project.endDate, + allowedRanges: project.allowedRanges, + participationOptions: project.participationOptions, + hosts: project.hosts.map(omitBrowserId), + guests: project.guests.map(omitBrowserId), + isHost: project.hosts.some((h) => h.browserId === browserId), + meAsGuest: guest ? omitBrowserId(guest) : null, + }; +} + +function omitBrowserId(entity: T): Omit { + const { browserId: _, ...rest } = entity; + return rest; +} + +export async function updateProject(browserId: string, projectId: string, input: UpdateProjectInput) { + const [host, existingGuest] = await Promise.all([ + prisma.host.findFirst({ + where: { + browserId, + projectId: projectId, + }, + }), + prisma.guest.findFirst({ + where: { projectId: projectId }, + }), + ]); + + if (!host) { + throw new ServiceError("not_host", "アクセス権限がありません。"); + } + + // 参加形態の更新 + if (input.participationOptions) { + if (input.participationOptions.length === 0) { + throw new ServiceError("options_empty", "参加形態は最低1つ必要です。"); + } + + // 削除対象の参加形態に Slot が紐づいているかチェック + const existingOptions = await prisma.participationOption.findMany({ + where: { projectId }, + include: { slots: { select: { id: true } } }, + }); + const newOptionIds = input.participationOptions.map((o) => o.id); + const optionsToDelete = existingOptions.filter((o) => !newOptionIds.includes(o.id)); + const undeletableOptions = optionsToDelete.filter((o) => o.slots.length > 0); + if (undeletableOptions.length > 0) { + const labels = undeletableOptions.map((o) => o.label).join(", "); + throw new ServiceError("option_in_use", `以下の参加形態は日程が登録されているため削除できません: ${labels}`); + } + + await prisma.$transaction([ + // 既存の参加形態で、新しいリストにないものを削除 + prisma.participationOption.deleteMany({ + where: { + projectId, + id: { + notIn: newOptionIds, + }, + }, + }), + // 既存の参加形態を更新または新規作成 + ...input.participationOptions.map((opt) => + prisma.participationOption.upsert({ + where: { id: opt.id }, + update: { label: opt.label, color: opt.color }, + create: { id: opt.id, label: opt.label, color: opt.color, projectId }, + }), + ), + ]); + } + + // ゲストの回答があれば、日程と時間帯は変えない + return prisma.project.update({ + where: { id: projectId }, + data: existingGuest + ? { + name: input.name, + description: input.description?.trim() || null, + } + : { + name: input.name, + description: input.description?.trim() || null, + startDate: input.startDate ? new Date(input.startDate) : undefined, + endDate: input.endDate ? new Date(input.endDate) : undefined, + allowedRanges: { + deleteMany: {}, // 既存削除 + create: input.allowedRanges?.map((r) => ({ + startTime: new Date(r.startTime), + endTime: new Date(r.endTime), + })), + }, + }, + include: { allowedRanges: true, participationOptions: true }, + }); +} + +export async function deleteProject(browserId: string, projectId: string) { + const host = await prisma.host.findUnique({ + where: { + browserId_projectId: { + browserId, + projectId, + }, + }, + }); + + if (!host) { + throw new ServiceError("not_host", "削除権限がありません。"); + } + + await prisma.project.delete({ + where: { id: projectId }, + }); +} + +/** Slot の検証に必要な分だけプロジェクトを読む */ +async function findProjectForSlots(projectId: string) { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { + startDate: true, + endDate: true, + allowedRanges: { select: { startTime: true, endTime: true } }, + participationOptions: { select: { id: true } }, + }, + }); + if (!project) { + throw new ServiceError("project_not_found", "イベントが見つかりません。"); + } + return project; +} + +export async function submitAvailability(browserId: string, projectId: string, input: SubmissionInput) { + const existingGuest = await prisma.guest.findUnique({ + where: { + browserId_projectId: { + browserId, + projectId, + }, + }, + }); + if (existingGuest) { + throw new ServiceError("already_submitted", "提出済みです。"); + } + + validateSlots(await findProjectForSlots(projectId), input.slots); + + return prisma.guest.create({ + data: { + name: input.name, + comment: input.comment?.trim() || null, + browserId, + project: { connect: { id: projectId } }, + slots: { + create: input.slots.map((slot) => ({ + from: slot.start, + to: slot.end, + projectId, + participationOptionId: slot.participationOptionId, + })), + }, + }, + include: { slots: true }, + }); +} + +export async function updateMyAvailability(browserId: string, projectId: string, input: SubmissionInput) { + const existingGuest = await prisma.guest.findUnique({ + where: { browserId_projectId: { browserId, projectId } }, + include: { slots: true }, + }); + + if (!existingGuest) { + throw new ServiceError("submission_not_found", "既存の日程が見つかりません。"); + } + + validateSlots(await findProjectForSlots(projectId), input.slots); + + const slotData = input.slots.map((slot) => ({ + from: slot.start, + to: slot.end, + projectId, + participationOptionId: slot.participationOptionId, + })); + + await prisma.slot.deleteMany({ where: { guestId: existingGuest.id } }); + + return prisma.guest.update({ + where: { id: existingGuest.id }, + data: { + slots: { create: slotData }, + name: input.name, + comment: input.comment?.trim() || null, + }, + include: { slots: true }, + }); +} diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts new file mode 100644 index 0000000..2572f66 --- /dev/null +++ b/server/src/services/slots.ts @@ -0,0 +1,90 @@ +import { ServiceError } from "./errors.js"; + +/** + * イベントの日付と時刻は、このタイムゾーンの壁時計で解釈する (AllowedRange は日付部分を使わず、時刻だけを使う)。 + * M3 で event.timezone に置き換える。 + */ +const TIMEZONE = "Asia/Tokyo"; + +const MINUTES_PER_DAY = 24 * 60; + +const wallClockFormat = new Intl.DateTimeFormat("en-CA", { + timeZone: TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + +/** TIMEZONE での日付 ("YYYY-MM-DD") と、0 時からの経過分 */ +function toWallClock(value: Date): { date: string; minutes: number } { + const parts = Object.fromEntries(wallClockFormat.formatToParts(value).map((p) => [p.type, p.value])); + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + minutes: Number(parts.hour) * 60 + Number(parts.minute), + }; +} + +function formatMinutes(minutes: number): string { + return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; +} + +/** 秒以下が 0 で、分が 15 の倍数か。TIMEZONE のオフセットは 15 分の倍数なので UTC で判定してよい。 */ +function isQuarterHour(value: Date): boolean { + return value.getTime() % (15 * 60 * 1000) === 0; +} + +type SlotInput = { start: Date; end: Date; participationOptionId: string }; + +type Project = { + startDate: Date; + endDate: Date; + allowedRanges: { startTime: Date; endTime: Date }[]; + participationOptions: { id: string }[]; +}; + +/** + * Slot がイベントの日程の範囲・時間帯・15 分単位に収まり、イベントの参加形態を使っているかを検証する。 + * Web UI はカレンダーの構造上これを満たすが、API を直接呼ぶと満たさない Slot を作れてしまう (#91)。 + */ +export function validateSlots(project: Project, slots: SlotInput[]): void { + const firstDate = toWallClock(project.startDate).date; + const lastDate = toWallClock(project.endDate).date; + // 時間帯は現在 1 つだけ。無ければ終日とみなす + const range = project.allowedRanges[0]; + const rangeStart = range ? toWallClock(range.startTime).minutes : 0; + const rangeEnd = range ? toWallClock(range.endTime).minutes : MINUTES_PER_DAY; + const optionIds = new Set(project.participationOptions.map((o) => o.id)); + + slots.forEach((slot, i) => { + const invalid = (message: string) => new ServiceError("invalid_slot", `${i + 1} 件目の時間帯: ${message}`); + + if (slot.start.getTime() >= slot.end.getTime()) { + throw invalid("開始時刻は終了時刻より前にしてください。"); + } + if (!isQuarterHour(slot.start) || !isQuarterHour(slot.end)) { + throw invalid("時刻は 15 分単位で指定してください。"); + } + + const start = toWallClock(slot.start); + const end = toWallClock(slot.end); + // [start, end) の最後の瞬間の日付。24:00 は翌日の 0:00 として届くので、こうしないと日をまたいだことになる + const lastInstantDate = toWallClock(new Date(slot.end.getTime() - 1)).date; + if (lastInstantDate !== start.date) { + throw invalid("日をまたいでいます。日付ごとに分けてください。"); + } + if (start.date < firstDate || start.date > lastDate) { + throw invalid(`イベントの日程の範囲 (${firstDate}〜${lastDate}) の外です。`); + } + const endMinutes = end.date === start.date ? end.minutes : MINUTES_PER_DAY; + if (start.minutes < rangeStart || endMinutes > rangeEnd) { + throw invalid(`イベントの時間帯 (${formatMinutes(rangeStart)}〜${formatMinutes(rangeEnd)}) の外です。`); + } + + if (!optionIds.has(slot.participationOptionId)) { + throw invalid("このイベントに無い参加形態です。"); + } + }); +} diff --git a/server/test/browserId.test.ts b/server/test/browserId.test.ts new file mode 100644 index 0000000..160543b --- /dev/null +++ b/server/test/browserId.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from "vitest"; +import { app } from "../src/app.js"; + +function browserIdCookie(res: Response) { + return res.headers.get("Set-Cookie")?.split(";")[0]; +} + +function requestWithCookie(cookie?: string) { + return app.request("/", { headers: cookie ? { Cookie: cookie } : {} }); +} + +describe("browserIdMiddleware", () => { + test("署名が不正な Cookie には 400 を返す", async () => { + const cookie = browserIdCookie(await requestWithCookie()); + + const res = await requestWithCookie(`${cookie}x`); + + expect(res.status).toBe(400); + expect(res.headers.get("Set-Cookie")).toBeNull(); + }); + + test("前の鍵で署名された Cookie は、同じ browserId を現行の鍵で署名し直して発行する", async () => { + vi.stubEnv("COOKIE_SECRET", "old-secret"); + const oldCookie = browserIdCookie(await requestWithCookie()); + vi.stubEnv("COOKIE_SECRET", "new-secret"); + vi.stubEnv("COOKIE_SECRET_PREVIOUS", "older-secret, old-secret"); + + const res = await requestWithCookie(oldCookie); + + expect(res.status).toBe(200); + const newCookie = browserIdCookie(res); + expect(newCookie).not.toBe(oldCookie); + expect(newCookie?.split(".")[0]).toBe(oldCookie?.split(".")[0]); + + // 署名し直した Cookie は、前の鍵を外しても通る + vi.stubEnv("COOKIE_SECRET_PREVIOUS", undefined); + expect((await requestWithCookie(newCookie)).status).toBe(200); + }); +}); diff --git a/server/test/env.ts b/server/test/env.ts new file mode 100644 index 0000000..224ce7a --- /dev/null +++ b/server/test/env.ts @@ -0,0 +1,3 @@ +/** テスト用の DB。開発用の DB (itsuhima_dev) を消さないよう、別のデータベースにする */ +export const testDatabaseUrl = + process.env.TEST_DATABASE_URL ?? "postgresql://postgres:password@localhost:5432/itsuhima_test"; diff --git a/server/test/globalSetup.ts b/server/test/globalSetup.ts new file mode 100644 index 0000000..5f6d0f8 --- /dev/null +++ b/server/test/globalSetup.ts @@ -0,0 +1,14 @@ +import { execFileSync } from "node:child_process"; +import { testDatabaseUrl } from "./env.js"; + +export default function setup() { + // setup.ts で毎回全テーブルを空にするので、誤って開発用や本番の DB に向けないようにする + const dbName = new URL(testDatabaseUrl).pathname.slice(1); + if (!dbName.includes("test")) { + throw new Error(`テスト用の DB 名には "test" を含めてください: ${dbName}`); + } + execFileSync("npx", ["prisma", "migrate", "deploy"], { + env: { ...process.env, DATABASE_URL: testDatabaseUrl }, + stdio: "inherit", + }); +} diff --git a/server/test/helpers.ts b/server/test/helpers.ts new file mode 100644 index 0000000..d14dcc3 --- /dev/null +++ b/server/test/helpers.ts @@ -0,0 +1,72 @@ +import { serializeSigned } from "hono/utils/cookie"; +import { app } from "../src/app.js"; + +/** + * 1 つのブラウザを再現する。browserId を決めて署名済みの Cookie を作り、すべてのリクエストに付ける。 + * サーバーは同じ browserId で Cookie を再発行するだけなので、レスポンスの Set-Cookie は見ない。 + */ +export function createBrowser() { + const browserId = crypto.randomUUID(); + // 署名は非同期なので、最初のリクエストのときに作る + let cookie: Promise | undefined; + + const request = async (path: string, init: RequestInit = {}) => { + cookie ??= serializeSigned("browserId", browserId, process.env.COOKIE_SECRET as string); + const headers = new Headers(init.headers); + headers.set("Cookie", await cookie); + return app.request(path, { ...init, headers }); + }; + const send = (method: string, path: string, body?: unknown) => + request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + return { + browserId, + get: (path: string) => request(path), + post: (path: string, body?: unknown) => send("POST", path, body), + put: (path: string, body?: unknown) => send("PUT", path, body), + delete: (path: string) => send("DELETE", path), + }; +} + +export type Browser = ReturnType; + +/** + * 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。Web UI と同じく、日時は ISO 文字列 (UTC) で送る。 + */ +export function projectInput(overrides: Record = {}) { + return { + name: "テストイベント", + description: "説明", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + allowedRanges: [{ startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }], + participationOptions: [{ id: crypto.randomUUID(), label: "参加", color: "#0F82B1" }], + ...overrides, + }; +} + +/** イベントを作成して、その id と参加形態の id を返す */ +export async function createProject(host: Browser, overrides: Record = {}) { + const input = projectInput(overrides); + const res = await host.post("/projects", input); + if (res.status !== 201) throw new Error(`イベントの作成に失敗しました: ${res.status} ${await res.text()}`); + const { id } = (await res.json()) as { id: string }; + return { id, input, optionId: input.participationOptions[0].id }; +} + +/** 2026/11/02 (JST) 10:00〜12:00 */ +export const slotOnNov2 = { start: "2026-11-02T01:00:00.000Z", end: "2026-11-02T03:00:00.000Z" }; + +export function submissionInput(projectId: string, optionId: string, overrides: Record = {}) { + return { + name: "ゲスト", + projectId, + comment: "コメント", + slots: [{ ...slotOnNov2, participationOptionId: optionId }], + ...overrides, + }; +} diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts new file mode 100644 index 0000000..d7cec00 --- /dev/null +++ b/server/test/projects.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, test } from "vitest"; +import { type Browser, createBrowser, createProject, projectInput, slotOnNov2, submissionInput } from "./helpers.js"; + +const missingProjectId = "a".repeat(21); + +async function getProject(browser: Browser, projectId: string) { + const res = await browser.get(`/projects/${projectId}`); + expect(res.status).toBe(200); + // biome-ignore lint/suspicious/noExplicitAny: レスポンスの形そのものを検証するため + return (await res.json()) as any; +} + +async function submit(guest: Browser, projectId: string, optionId: string, overrides: Record = {}) { + const res = await guest.post(`/projects/${projectId}/submissions`, submissionInput(projectId, optionId, overrides)); + expect(res.status).toBe(201); +} + +describe("POST /projects", () => { + test("作成すると 201 で id と name を返し、作成者がホストになる", async () => { + const host = createBrowser(); + const input = projectInput({ description: " 説明 " }); + + const res = await host.post("/projects", input); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toEqual({ id: expect.stringMatching(/^[0-9A-Za-z]{21}$/), name: "テストイベント" }); + + const project = await getProject(host, body.id); + expect(project).toEqual({ + id: body.id, + name: "テストイベント", + description: "説明", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + allowedRanges: [ + { + id: expect.any(String), + projectId: body.id, + startTime: "2026-11-01T23:00:00.000Z", + endTime: "2026-11-04T14:00:00.000Z", + }, + ], + participationOptions: [{ ...input.participationOptions[0], projectId: body.id }], + hosts: [{ id: expect.any(String), projectId: body.id }], + guests: [], + isHost: true, + meAsGuest: null, + }); + }); + + test("空白だけの説明は空文字として返す", async () => { + const host = createBrowser(); + const { id } = await createProject(host, { description: " " }); + + expect((await getProject(host, id)).description).toBe(""); + }); + + test("名前が空なら 400", async () => { + const res = await createBrowser().post("/projects", projectInput({ name: "" })); + + expect(res.status).toBe(400); + }); + + test("開始日が終了日より後なら 400", async () => { + const res = await createBrowser().post( + "/projects", + projectInput({ startDate: "2026-11-05T15:00:00.000Z", endDate: "2026-11-04T14:59:59.999Z" }), + ); + + expect(res.status).toBe(400); + }); + + test("参加形態が 0 件なら 400", async () => { + const res = await createBrowser().post("/projects", projectInput({ participationOptions: [] })); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /projects/mine", () => { + test("ホストまたはゲストとして関わるイベントだけを返す", async () => { + const me = createBrowser(); + const other = createBrowser(); + const hosted = await createProject(me, { name: "主催" }); + const joined = await createProject(other, { name: "参加" }); + await createProject(other, { name: "無関係" }); + await submit(me, joined.id, joined.optionId); + + const res = await me.get("/projects/mine"); + + expect(res.status).toBe(200); + const body = (await res.json()) as { name: string }[]; + expect(body.sort((a, b) => a.name.localeCompare(b.name))).toEqual([ + { + id: hosted.id, + name: "主催", + description: "説明", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + isHost: true, + }, + { + id: joined.id, + name: "参加", + description: "説明", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + isHost: false, + }, + ]); + }); + + test("関わるイベントが無ければ空配列", async () => { + const res = await createBrowser().get("/projects/mine"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); +}); + +describe("GET /projects/:projectId", () => { + test("存在しなければ 404", async () => { + const res = await createBrowser().get(`/projects/${missingProjectId}`); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ message: "イベントが見つかりません。" }); + }); + + test("id が 21 文字でなければ 400", async () => { + const res = await createBrowser().get("/projects/short"); + + expect(res.status).toBe(400); + }); + + test("ゲストから見ると isHost は false で、自分の回答が meAsGuest に入る", async () => { + const host = createBrowser(); + const guest = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(guest, id, optionId, { comment: " よろしく " }); + + const project = await getProject(guest, id); + + const expectedGuest = { + id: expect.any(String), + name: "ゲスト", + comment: "よろしく", + projectId: id, + slots: [ + { + id: expect.any(String), + from: slotOnNov2.start, + to: slotOnNov2.end, + projectId: id, + guestId: expect.any(String), + participationOptionId: optionId, + }, + ], + }; + expect(project.isHost).toBe(false); + expect(project.meAsGuest).toEqual(expectedGuest); + expect(project.guests).toEqual([expectedGuest]); + }); + + test("回答していない人から見ると meAsGuest は null", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(createBrowser(), id, optionId); + + const project = await getProject(createBrowser(), id); + + expect(project.isHost).toBe(false); + expect(project.meAsGuest).toBeNull(); + expect(project.guests).toHaveLength(1); + }); + + test("ホストとゲストの browserId をレスポンスに含めない", async () => { + const host = createBrowser(); + const guest = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(guest, id, optionId); + + const res = await guest.get(`/projects/${id}`); + const text = await res.text(); + + expect(text).not.toContain("browserId"); + expect(text).not.toContain(host.browserId); + expect(text).not.toContain(guest.browserId); + }); +}); + +describe("PUT /projects/:projectId", () => { + test("ホスト以外は 403", async () => { + const { id } = await createProject(createBrowser()); + + const res = await createBrowser().put(`/projects/${id}`, { name: "乗っ取り" }); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ message: "アクセス権限がありません。" }); + }); + + test("存在しないイベントは 403", async () => { + const res = await createBrowser().put(`/projects/${missingProjectId}`, { name: "x" }); + + expect(res.status).toBe(403); + }); + + test("ゲストがいなければ、名前・説明・日程・時間帯を更新できる", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + const res = await host.put(`/projects/${id}`, { + name: "新しい名前", + description: " 新しい説明 ", + startDate: "2026-11-09T15:00:00.000Z", + endDate: "2026-11-10T14:59:59.999Z", + allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + }); + + expect(res.status).toBe(200); + const { event } = await res.json(); + expect(event).toMatchObject({ + id, + name: "新しい名前", + description: "新しい説明", + startDate: "2026-11-09T15:00:00.000Z", + endDate: "2026-11-10T14:59:59.999Z", + allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + }); + expect(event.participationOptions).toHaveLength(1); + }); + + test("ゲストがいると、名前と説明だけ更新し、日程と時間帯は変えない", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(createBrowser(), id, optionId); + + const res = await host.put(`/projects/${id}`, { + name: "新しい名前", + startDate: "2026-11-09T15:00:00.000Z", + endDate: "2026-11-10T14:59:59.999Z", + allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + }); + + expect(res.status).toBe(200); + const project = await getProject(host, id); + expect(project).toMatchObject({ + name: "新しい名前", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + allowedRanges: [{ startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }], + }); + }); + + // 現状の挙動: 省略したフィールドは「変更しない」ではなく、消える + test("description を省略すると、説明が消える", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + await host.put(`/projects/${id}`, { name: "新しい名前" }); + + expect((await getProject(host, id)).description).toBe(""); + }); + + // 現状の挙動: 省略したフィールドは「変更しない」ではなく、消える + test("ゲストがいないときに allowedRanges を省略すると、時間帯が消える", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + await host.put(`/projects/${id}`, { name: "新しい名前" }); + + expect((await getProject(host, id)).allowedRanges).toEqual([]); + }); + + test("参加形態を追加・変更・削除できる", async () => { + const host = createBrowser(); + const { id } = await createProject(host, { + participationOptions: [ + { id: crypto.randomUUID(), label: "参加", color: "#0F82B1" }, + { id: crypto.randomUUID(), label: "消す", color: "#000000" }, + ], + }); + const [kept] = (await getProject(host, id)).participationOptions.filter( + (o: { label: string }) => o.label === "参加", + ); + const added = { id: crypto.randomUUID(), label: "オンライン", color: "#FF0000" }; + + const res = await host.put(`/projects/${id}`, { + participationOptions: [{ id: kept.id, label: "対面", color: "#00FF00" }, added], + }); + + expect(res.status).toBe(200); + const options = (await getProject(host, id)).participationOptions; + expect(options.sort((a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label))).toEqual([ + { ...added, projectId: id }, + { id: kept.id, label: "対面", color: "#00FF00", projectId: id }, + ]); + }); + + test("Slot が紐づく参加形態は削除できない", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(createBrowser(), id, optionId); + + const res = await host.put(`/projects/${id}`, { + participationOptions: [{ id: crypto.randomUUID(), label: "別", color: "#000000" }], + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ message: "以下の参加形態は日程が登録されているため削除できません: 参加" }); + expect((await getProject(host, id)).participationOptions).toHaveLength(1); + }); + + test("参加形態を 0 件にはできない", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + const res = await host.put(`/projects/${id}`, { participationOptions: [] }); + + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /projects/:projectId", () => { + test("ホスト以外は 403", async () => { + const { id } = await createProject(createBrowser()); + + const res = await createBrowser().delete(`/projects/${id}`); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ message: "削除権限がありません。" }); + }); + + // 現状の挙動: `c.json(204)` なので、ステータスは 200 で本文が 204 になる + test("ホストは、ゲストの回答ごと削除できる", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(createBrowser(), id, optionId); + + const res = await host.delete(`/projects/${id}`); + + expect(res.status).toBe(200); + expect(await res.json()).toBe(204); + expect((await host.get(`/projects/${id}`)).status).toBe(404); + }); +}); + +describe("POST /projects/:projectId/submissions", () => { + test("回答すると 201", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + + const res = await guest.post(`/projects/${id}/submissions`, submissionInput(id, optionId)); + + expect(res.status).toBe(201); + expect(await res.json()).toBe("日程が提出されました。"); + expect((await getProject(guest, id)).meAsGuest).toMatchObject({ name: "ゲスト", comment: "コメント" }); + }); + + test("空白だけのコメントは null になる", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + + await submit(guest, id, optionId, { comment: " " }); + + expect((await getProject(guest, id)).meAsGuest.comment).toBeNull(); + }); + + test("同じブラウザから 2 回目は 409", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + await submit(guest, id, optionId); + + const res = await guest.post(`/projects/${id}/submissions`, submissionInput(id, optionId)); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ message: "提出済みです。" }); + }); + + test("ホストも回答できる", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + + await submit(host, id, optionId); + + const project = await getProject(host, id); + expect(project.isHost).toBe(true); + expect(project.meAsGuest).not.toBeNull(); + }); + + test("名前が空なら 400", async () => { + const { id, optionId } = await createProject(createBrowser()); + + const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(id, optionId, { name: "" })); + + expect(res.status).toBe(400); + }); + + test("存在しないイベントに回答すると 404", async () => { + const res = await createBrowser().post( + `/projects/${missingProjectId}/submissions`, + submissionInput(missingProjectId, crypto.randomUUID(), { slots: [] }), + ); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ message: "イベントが見つかりません。" }); + }); +}); + +// 検証の中身は slots.test.ts で確かめる。ここでは、提出と更新で検証が効いていることだけを確かめる +describe("日程の提出・更新での Slot の検証", () => { + test("イベントの期間外なら 400", async () => { + const { id, optionId } = await createProject(createBrowser()); + + const res = await createBrowser().post( + `/projects/${id}/submissions`, + submissionInput(id, optionId, { + // 11/01 10:00〜12:00 + slots: [ + { start: "2026-11-01T01:00:00.000Z", end: "2026-11-01T03:00:00.000Z", participationOptionId: optionId }, + ], + }), + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ message: expect.stringContaining("日程の範囲") }); + }); + + test("別のイベントの参加形態なら 400", async () => { + const { id } = await createProject(createBrowser()); + const other = await createProject(createBrowser()); + + const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(id, other.optionId)); + + expect(res.status).toBe(400); + }); + + test("回答の更新でも検証し、弾いたときは元の回答を変えない", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + await submit(guest, id, optionId); + + const res = await guest.put( + `/projects/${id}/submissions/mine`, + submissionInput(id, optionId, { + name: "新しい名前", + // 11/01 10:00〜12:00 + slots: [ + { start: "2026-11-01T01:00:00.000Z", end: "2026-11-01T03:00:00.000Z", participationOptionId: optionId }, + ], + }), + ); + + expect(res.status).toBe(400); + const { meAsGuest } = await getProject(guest, id); + expect(meAsGuest).toMatchObject({ name: "ゲスト", slots: [{ from: slotOnNov2.start, to: slotOnNov2.end }] }); + }); +}); + +describe("PUT /projects/:projectId/submissions/mine", () => { + test("回答していなければ 404", async () => { + const { id, optionId } = await createProject(createBrowser()); + + const res = await createBrowser().put(`/projects/${id}/submissions/mine`, submissionInput(id, optionId)); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ message: "既存の日程が見つかりません。" }); + }); + + test("名前・コメント・Slot を置き換える", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + await submit(guest, id, optionId); + const newSlot = { + start: "2026-11-03T05:00:00.000Z", + end: "2026-11-03T06:00:00.000Z", + participationOptionId: optionId, + }; + + const res = await guest.put( + `/projects/${id}/submissions/mine`, + submissionInput(id, optionId, { name: "新しい名前", comment: "", slots: [newSlot] }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + message: "日程が更新されました。", + guest: { name: "新しい名前", comment: null, slots: [{ from: newSlot.start, to: newSlot.end }] }, + }); + const { meAsGuest } = await getProject(guest, id); + expect(meAsGuest.slots).toEqual([expect.objectContaining({ from: newSlot.start, to: newSlot.end })]); + }); + + test("他のゲストの回答は変えない", async () => { + const { id, optionId } = await createProject(createBrowser()); + const guest = createBrowser(); + const other = createBrowser(); + await submit(guest, id, optionId); + await submit(other, id, optionId, { name: "他の人" }); + + await guest.put(`/projects/${id}/submissions/mine`, submissionInput(id, optionId, { slots: [] })); + + const { meAsGuest } = await getProject(other, id); + expect(meAsGuest).toMatchObject({ name: "他の人", slots: [expect.anything()] }); + }); +}); diff --git a/server/test/setup.ts b/server/test/setup.ts new file mode 100644 index 0000000..65ccc54 --- /dev/null +++ b/server/test/setup.ts @@ -0,0 +1,12 @@ +import { afterAll, beforeEach } from "vitest"; +import { prisma } from "../src/db.js"; + +beforeEach(async () => { + await prisma.$executeRawUnsafe( + 'TRUNCATE "Project", "AllowedRange", "Slot", "Host", "Guest", "ParticipationOption" CASCADE', + ); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts new file mode 100644 index 0000000..4d10917 --- /dev/null +++ b/server/test/slots.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vitest"; +import { ServiceError } from "../src/services/errors.js"; +import { validateSlots } from "../src/services/slots.js"; + +// 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。時刻はコメントに JST で書く +const project = { + startDate: new Date("2026-11-01T15:00:00.000Z"), + endDate: new Date("2026-11-04T14:59:59.999Z"), + allowedRanges: [{ startTime: new Date("2026-11-01T23:00:00.000Z"), endTime: new Date("2026-11-04T14:00:00.000Z") }], + participationOptions: [{ id: "option" }], +}; +const allDayProject = { ...project, allowedRanges: [] }; + +function slot(start: string, end: string, participationOptionId = "option") { + return { start: new Date(start), end: new Date(end), participationOptionId }; +} + +function validationError(fn: () => void): ServiceError { + try { + fn(); + } catch (err) { + if (err instanceof ServiceError) return err; + throw err; + } + throw new Error("ServiceError が投げられませんでした"); +} + +describe("validateSlots", () => { + test("期間と時間帯に収まる Slot は通す", () => { + expect(() => + validateSlots(project, [ + // 11/02 08:00〜10:00 (初日、時間帯の始まりちょうどから) + slot("2026-11-01T23:00:00.000Z", "2026-11-02T01:00:00.000Z"), + // 11/04 21:00〜23:00 (最終日、時間帯の終わりちょうどまで) + slot("2026-11-04T12:00:00.000Z", "2026-11-04T14:00:00.000Z"), + ]), + ).not.toThrow(); + }); + + test("Slot が 0 件なら通す", () => { + expect(() => validateSlots(project, [])).not.toThrow(); + }); + + test("期間の前日と翌日は弾く", () => { + // 11/01 10:00〜12:00 + const before = validationError(() => + validateSlots(project, [slot("2026-11-01T01:00:00.000Z", "2026-11-01T03:00:00.000Z")]), + ); + // 11/05 10:00〜12:00 + const after = validationError(() => + validateSlots(project, [slot("2026-11-05T01:00:00.000Z", "2026-11-05T03:00:00.000Z")]), + ); + + expect(before.reason).toBe("invalid_slot"); + expect(before.message).toBe("1 件目の時間帯: イベントの日程の範囲 (2026-11-02〜2026-11-04) の外です。"); + expect(after.reason).toBe("invalid_slot"); + }); + + test("時間帯より前に始まる Slot は弾く", () => { + // 11/02 07:45〜09:00 + const err = validationError(() => + validateSlots(project, [slot("2026-11-01T22:45:00.000Z", "2026-11-02T00:00:00.000Z")]), + ); + + expect(err.message).toBe("1 件目の時間帯: イベントの時間帯 (08:00〜23:00) の外です。"); + }); + + test("時間帯より後に終わる Slot は弾く", () => { + // 11/02 22:00〜23:15 + const err = validationError(() => + validateSlots(project, [slot("2026-11-02T13:00:00.000Z", "2026-11-02T14:15:00.000Z")]), + ); + + expect(err.reason).toBe("invalid_slot"); + }); + + test("15 分単位でない Slot は弾く", () => { + // 11/02 10:10〜12:00 + const minutes = validationError(() => + validateSlots(project, [slot("2026-11-02T01:10:00.000Z", "2026-11-02T03:00:00.000Z")]), + ); + // 11/02 10:00:30〜12:00 + const seconds = validationError(() => + validateSlots(project, [slot("2026-11-02T01:00:30.000Z", "2026-11-02T03:00:00.000Z")]), + ); + + expect(minutes.reason).toBe("invalid_slot"); + expect(seconds.reason).toBe("invalid_slot"); + }); + + test("開始が終了より前でない Slot は弾く", () => { + // 11/02 12:00〜12:00 + const empty = validationError(() => + validateSlots(project, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T03:00:00.000Z")]), + ); + // 11/02 12:00〜10:00 + const reversed = validationError(() => + validateSlots(project, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T01:00:00.000Z")]), + ); + + expect(empty.reason).toBe("invalid_slot"); + expect(reversed.reason).toBe("invalid_slot"); + }); + + test("日をまたぐ Slot は弾く", () => { + // 11/02 23:00〜11/03 01:00 + const err = validationError(() => + validateSlots(allDayProject, [slot("2026-11-02T14:00:00.000Z", "2026-11-02T16:00:00.000Z")]), + ); + + expect(err.message).toBe("1 件目の時間帯: 日をまたいでいます。日付ごとに分けてください。"); + }); + + // 時間帯が無いと、カレンダーは 23:45〜24:00 のセルを描画し、終わりを翌日の 0:00 として送る + test("時間帯が無ければ終日とみなし、最終日の 23:45〜24:00 も通す", () => { + expect(() => + validateSlots(allDayProject, [ + // 11/02 00:00〜01:00 + slot("2026-11-01T15:00:00.000Z", "2026-11-01T16:00:00.000Z"), + // 11/04 23:45〜11/05 00:00 + slot("2026-11-04T14:45:00.000Z", "2026-11-04T15:00:00.000Z"), + ]), + ).not.toThrow(); + }); + + test("イベントに無い参加形態は弾く", () => { + const err = validationError(() => + validateSlots(project, [slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z", "other")]), + ); + + expect(err.message).toBe("1 件目の時間帯: このイベントに無い参加形態です。"); + }); + + test("何件目の Slot が違反したかをメッセージに含める", () => { + const err = validationError(() => + validateSlots(project, [ + // 11/02 10:00〜12:00 + slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z"), + // 11/01 10:00〜12:00 + slot("2026-11-01T01:00:00.000Z", "2026-11-01T03:00:00.000Z"), + ]), + ); + + expect(err.message).toMatch(/^2 件目の時間帯: /); + }); +}); diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 0000000..8869140 --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src/**/*"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json index d79ab90..e0dbd80 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "noEmit": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "test/**/*", "vitest.config.ts"] } diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..54af0e1 --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import { testDatabaseUrl } from "./test/env.js"; + +export default defineConfig({ + test: { + globalSetup: ["./test/globalSetup.ts"], + setupFiles: ["./test/setup.ts"], + // 全テストで 1 つの DB を共有するので、ファイルを並列に流さない + fileParallelism: false, + unstubEnvs: true, + restoreMocks: true, + env: { + DATABASE_URL: testDatabaseUrl, + COOKIE_SECRET: "test-secret", + DOMAIN: "localhost", + }, + }, +});