diff --git a/package-lock.json b/package-lock.json index 017d505..4c00db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,7 +129,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -155,18 +154,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -681,30 +668,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/config-validator/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@commitlint/ensure": { "version": "20.5.0", "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", @@ -858,16 +821,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@commitlint/rules": { "version": "20.5.0", "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", @@ -1036,7 +989,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1085,7 +1037,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1153,23 +1104,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/@dotenvx/dotenvx/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -1239,19 +1173,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@dotenvx/dotenvx/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1296,6 +1217,29 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1360,6 +1304,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -1539,6 +1507,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@inquirer/core/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1725,28 +1702,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -1942,7 +1897,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2071,7 +2025,6 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -2470,16 +2423,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2612,7 +2555,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2652,7 +2596,6 @@ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2670,7 +2613,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2682,7 +2624,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2744,7 +2685,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3416,7 +3356,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3444,16 +3383,15 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3477,28 +3415,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -3560,6 +3476,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3574,13 +3503,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -3833,9 +3762,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", - "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3933,7 +3862,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4272,6 +4200,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4344,13 +4281,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, "node_modules/compare-func": { @@ -4482,7 +4418,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4522,16 +4457,6 @@ "typescript": ">=5" } }, - "node_modules/cosmiconfig-typescript-loader/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4881,7 +4806,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -4896,16 +4822,6 @@ "node": ">=8" } }, - "node_modules/dot-prop/node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -5244,7 +5160,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5414,7 +5329,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5506,7 +5420,17 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, - "node_modules/eslint-plugin-react": { + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", @@ -5605,6 +5529,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -5770,7 +5718,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5900,6 +5847,23 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -6108,9 +6072,9 @@ "license": "ISC" }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6292,9 +6256,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6566,11 +6530,10 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6687,6 +6650,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -6984,12 +6956,19 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-generator-function": { @@ -7125,15 +7104,13 @@ } }, "node_modules/is-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", - "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-path-inside": { @@ -7418,14 +7395,13 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/jose": { @@ -7496,16 +7472,6 @@ } } }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7532,10 +7498,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -7552,16 +7517,15 @@ "license": "MIT" }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/jsonfile": { @@ -7949,29 +7913,6 @@ "url": "https://opencollective.com/lint-staged" } }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -8233,22 +8174,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update/node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -8331,11 +8256,14 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/lucide-react": { "version": "1.8.0", @@ -8352,6 +8280,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8444,6 +8373,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -9359,6 +9300,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -9379,12 +9327,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -9451,21 +9399,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9495,7 +9428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9640,7 +9572,7 @@ "postcss": "^8.2.14" } }, - "node_modules/postcss-selector-parser": { + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", @@ -9654,6 +9586,19 @@ "node": ">=4" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -9684,9 +9629,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -9705,6 +9650,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9720,6 +9666,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9727,13 +9674,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -9783,6 +9723,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9870,7 +9817,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9883,7 +9829,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9893,11 +9838,12 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/read-cache": { "version": "1.0.0", @@ -9922,6 +9868,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -10045,12 +10004,13 @@ } }, "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/resolve-pkg-maps": { @@ -10484,40 +10444,6 @@ "shadcn": "dist/index.js" } }, - "node_modules/shadcn/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/shadcn/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/shadcn/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/shadcn/node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -10680,22 +10606,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10738,9 +10648,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -10835,6 +10745,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -10994,6 +10914,18 @@ "url": "https://github.com/yeoman/stringify-object?sponsor=1" } }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11113,6 +11045,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11206,6 +11148,30 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/tailwindcss/node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -11298,38 +11264,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -11447,6 +11381,19 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11586,7 +11533,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11784,7 +11730,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -11857,17 +11802,19 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/vitest": { @@ -11960,19 +11907,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -12205,6 +12139,16 @@ "dev": true, "license": "MIT" }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -12365,6 +12309,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -12436,7 +12389,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/api/google/auth/route.ts b/src/app/api/google/auth/route.ts new file mode 100644 index 0000000..1bf9b3b --- /dev/null +++ b/src/app/api/google/auth/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { buildAuthUrl } from "@/lib/google/auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/auth + * Google OAuth 동의 화면으로 리다이렉트한다. + */ +export async function GET() { + const authUrl = buildAuthUrl(); + return NextResponse.redirect(authUrl); +} diff --git a/src/app/api/google/calendars/route.ts b/src/app/api/google/calendars/route.ts new file mode 100644 index 0000000..95e680d --- /dev/null +++ b/src/app/api/google/calendars/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getValidTokens } from "@/lib/google/auth"; +import { listCalendars } from "@/lib/google/calendars"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/calendars + * 연결된 Google 계정의 캘린더 목록을 반환한다. + */ +export async function GET() { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { + error: + "Google 계정이 연결되지 않았습니다. /api/google/auth로 인증해주세요.", + }, + { status: 401 }, + ); + } + + try { + const calendars = await listCalendars(tokens.accessToken); + return NextResponse.json({ calendars }); + } catch (err) { + console.error("[google.calendars] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다. 다시 연결해주세요." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "캘린더 목록 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/google/callback/route.ts b/src/app/api/google/callback/route.ts new file mode 100644 index 0000000..2b77b7a --- /dev/null +++ b/src/app/api/google/callback/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + exchangeCodeForTokens, + getUserEmail, + saveTokensToCookie, +} from "@/lib/google/auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/callback + * Google OAuth 콜백. authorization code를 토큰으로 교환한 뒤 쿠키에 저장한다. + */ +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + const error = req.nextUrl.searchParams.get("error"); + + if (error) { + return NextResponse.json( + { error: `Google OAuth 거부: ${error}` }, + { status: 400 }, + ); + } + + if (!code) { + return NextResponse.json( + { error: "authorization code가 없습니다." }, + { status: 400 }, + ); + } + + try { + const tokens = await exchangeCodeForTokens(code); + const email = await getUserEmail(tokens.accessToken); + + await saveTokensToCookie(tokens); + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; + return NextResponse.redirect( + `${baseUrl}/api/google/calendars?connected=true&email=${encodeURIComponent(email)}`, + ); + } catch (err) { + console.error("[google.callback] 오류:", err); + return NextResponse.json( + { error: "Google 계정 연결에 실패했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/google/events/create/route.ts b/src/app/api/google/events/create/route.ts new file mode 100644 index 0000000..5ba1435 --- /dev/null +++ b/src/app/api/google/events/create/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getValidTokens } from "@/lib/google/auth"; +import { createEvent } from "@/lib/google/events"; + +export const dynamic = "force-dynamic"; + +const CreateEventSchema = z.object({ + calendarId: z.string().min(1, "calendarId는 필수입니다."), + summary: z + .string() + .min(1, "제목은 1자 이상이어야 합니다.") + .max(255, "제목은 255자 이하여야 합니다."), + startDateTime: z.string().datetime({ + offset: true, + message: "startDateTime은 ISO 8601 형식이어야 합니다.", + }), + endDateTime: z.string().datetime({ + offset: true, + message: "endDateTime은 ISO 8601 형식이어야 합니다.", + }), + location: z.string().max(500).optional(), + description: z.string().max(8000).optional(), + timeZone: z.string().optional(), +}); + +/** + * POST /api/google/events/create + * Google Calendar에 새 일정을 생성한다. + */ +export async function POST(req: NextRequest) { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { error: "Google 계정이 연결되지 않았습니다." }, + { status: 401 }, + ); + } + + const body = await req.json().catch(() => null); + const parsed = CreateEventSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 }, + ); + } + + const { calendarId, summary, startDateTime, endDateTime, ...rest } = + parsed.data; + + if (new Date(startDateTime) >= new Date(endDateTime)) { + return NextResponse.json( + { error: "endDateTime은 startDateTime보다 나중이어야 합니다." }, + { status: 400 }, + ); + } + + try { + const event = await createEvent(tokens.accessToken, calendarId, { + summary, + startDateTime, + endDateTime, + ...rest, + }); + + return NextResponse.json({ event }, { status: 201 }); + } catch (err) { + console.error("[google.events.create] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "일정 생성 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/google/events/query/route.ts b/src/app/api/google/events/query/route.ts new file mode 100644 index 0000000..5ccc9d5 --- /dev/null +++ b/src/app/api/google/events/query/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getValidTokens } from "@/lib/google/auth"; +import { queryEvents } from "@/lib/google/events"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/events/query?calendarId=...&startDate=...&endDate=... + * 특정 캘린더의 기간별 일정을 조회한다. + */ +export async function GET(req: NextRequest) { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { error: "Google 계정이 연결되지 않았습니다." }, + { status: 401 }, + ); + } + + const calendarId = req.nextUrl.searchParams.get("calendarId"); + const startDate = req.nextUrl.searchParams.get("startDate"); + const endDate = req.nextUrl.searchParams.get("endDate"); + + if (!calendarId || !startDate || !endDate) { + return NextResponse.json( + { error: "calendarId, startDate, endDate 파라미터가 필요합니다." }, + { status: 400 }, + ); + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return NextResponse.json( + { error: "날짜 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요." }, + { status: 400 }, + ); + } + + if (start >= end) { + return NextResponse.json( + { error: "endDate는 startDate보다 나중이어야 합니다." }, + { status: 400 }, + ); + } + + try { + const events = await queryEvents( + tokens.accessToken, + calendarId, + start, + end, + ); + return NextResponse.json({ events }); + } catch (err) { + console.error("[google.events.query] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "일정 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5178249..37d652e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,17 +1,15 @@ import type { Metadata } from "next"; -import localFont from "next/font/local"; +import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", +const geistSans = Inter({ + subsets: ["latin"], variable: "--font-geist-sans", - weight: "100 900", }); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", +const geistMono = JetBrains_Mono({ + subsets: ["latin"], variable: "--font-geist-mono", - weight: "100 900", }); export const metadata: Metadata = { diff --git a/src/lib/google/__tests__/auth.test.ts b/src/lib/google/__tests__/auth.test.ts new file mode 100644 index 0000000..9a4c67e --- /dev/null +++ b/src/lib/google/__tests__/auth.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// next/headers의 cookies를 모킹 +const mockCookieStore = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +}; + +vi.mock("next/headers", () => ({ + cookies: () => Promise.resolve(mockCookieStore), +})); + +import { + buildAuthUrl, + exchangeCodeForTokens, + refreshAccessToken, +} from "../auth"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockCookieStore.get.mockReset(); + mockCookieStore.set.mockReset(); + process.env.GOOGLE_CLIENT_ID = "test-client-id"; + process.env.GOOGLE_CLIENT_SECRET = "test-client-secret"; + process.env.NEXT_PUBLIC_BASE_URL = "http://localhost:3000"; +}); + +describe("buildAuthUrl", () => { + test("Google OAuth URL을 올바르게 생성한다", () => { + const url = buildAuthUrl(); + + expect(url).toContain("https://accounts.google.com/o/oauth2/v2/auth"); + expect(url).toContain("client_id=test-client-id"); + expect(url).toContain( + "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fgoogle%2Fcallback", + ); + expect(url).toContain("response_type=code"); + expect(url).toContain("access_type=offline"); + expect(url).toContain("prompt=consent"); + }); + + test("calendar 스코프가 포함된다", () => { + const url = buildAuthUrl(); + + expect(url).toContain("calendar"); + expect(url).toContain("userinfo.email"); + }); + + test("state 파라미터를 전달할 수 있다", () => { + const url = buildAuthUrl("my-state-value"); + + expect(url).toContain("state=my-state-value"); + }); +}); + +describe("exchangeCodeForTokens", () => { + test("authorization code로 토큰을 교환한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "access-123", + refresh_token: "refresh-456", + expires_in: 3600, + }), + }); + + const tokens = await exchangeCodeForTokens("auth-code-789"); + + expect(tokens.accessToken).toBe("access-123"); + expect(tokens.refreshToken).toBe("refresh-456"); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + + // POST 요청 검증 + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://oauth2.googleapis.com/token"); + expect(options.method).toBe("POST"); + expect(options.body.toString()).toContain("code=auth-code-789"); + expect(options.body.toString()).toContain("grant_type=authorization_code"); + }); + + test("실패 시 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + await expect(exchangeCodeForTokens("bad-code")).rejects.toThrow( + "토큰 교환 실패", + ); + }); +}); + +describe("refreshAccessToken", () => { + test("refresh token으로 새 access token을 발급받는다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + expires_in: 3600, + }), + }); + + const result = await refreshAccessToken("my-refresh-token"); + + expect(result.accessToken).toBe("new-access-token"); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + + const body = mockFetch.mock.calls[0][1].body.toString(); + expect(body).toContain("refresh_token=my-refresh-token"); + expect(body).toContain("grant_type=refresh_token"); + }); + + test("실패 시 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("token_revoked"), + }); + + await expect(refreshAccessToken("revoked-token")).rejects.toThrow( + "토큰 갱신 실패", + ); + }); +}); diff --git a/src/lib/google/__tests__/calendars.test.ts b/src/lib/google/__tests__/calendars.test.ts new file mode 100644 index 0000000..dbb16cb --- /dev/null +++ b/src/lib/google/__tests__/calendars.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { listCalendars } from "../calendars"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("listCalendars", () => { + test("캘린더 목록을 올바르게 파싱한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [ + { + id: "primary", + summary: "내 캘린더", + backgroundColor: "#4285f4", + primary: true, + accessRole: "owner", + }, + { + id: "work@gmail.com", + summary: "업무", + description: "업무 일정", + backgroundColor: "#0b8043", + accessRole: "writer", + }, + ], + }), + }); + + const calendars = await listCalendars("test-access-token"); + + expect(calendars).toHaveLength(2); + expect(calendars[0]).toMatchObject({ + id: "primary", + summary: "내 캘린더", + primary: true, + }); + expect(calendars[1]).toMatchObject({ + id: "work@gmail.com", + summary: "업무", + description: "업무 일정", + }); + }); + + test("Authorization 헤더를 올바르게 설정한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await listCalendars("my-token"); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer my-token"); + }); + + test("minAccessRole=writer로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await listCalendars("token"); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("minAccessRole=writer"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect(listCalendars("expired-token")).rejects.toThrow("만료"); + }); + + test("items가 없으면 빈 배열을 반환한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const calendars = await listCalendars("token"); + expect(calendars).toEqual([]); + }); +}); diff --git a/src/lib/google/__tests__/events.test.ts b/src/lib/google/__tests__/events.test.ts new file mode 100644 index 0000000..ef75893 --- /dev/null +++ b/src/lib/google/__tests__/events.test.ts @@ -0,0 +1,261 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { queryEvents, createEvent } from "../events"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("queryEvents", () => { + test("기간별 이벤트를 조회한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "event-1", + summary: "팀 미팅", + start: { dateTime: "2026-04-15T10:00:00+09:00" }, + end: { dateTime: "2026-04-15T11:00:00+09:00" }, + status: "confirmed", + htmlLink: "https://calendar.google.com/event?id=1", + }, + { + id: "event-2", + summary: "점심", + start: { dateTime: "2026-04-15T12:00:00+09:00" }, + end: { dateTime: "2026-04-15T13:00:00+09:00" }, + status: "confirmed", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events).toHaveLength(2); + expect(events[0].summary).toBe("팀 미팅"); + expect(events[1].summary).toBe("점심"); + }); + + test("취소된 이벤트를 필터링한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "active", + summary: "활성 이벤트", + start: { dateTime: "2026-04-15T10:00:00Z" }, + end: { dateTime: "2026-04-15T11:00:00Z" }, + status: "confirmed", + }, + { + id: "cancelled", + summary: "취소된 이벤트", + start: { dateTime: "2026-04-15T14:00:00Z" }, + end: { dateTime: "2026-04-15T15:00:00Z" }, + status: "cancelled", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events).toHaveLength(1); + expect(events[0].id).toBe("active"); + }); + + test("singleEvents=true와 orderBy=startTime으로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await queryEvents( + "token", + "primary", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("singleEvents=true"); + expect(url).toContain("orderBy=startTime"); + }); + + test("calendarId를 URL 인코딩한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await queryEvents( + "token", + "user@gmail.com", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("user%40gmail.com"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect( + queryEvents( + "expired", + "primary", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ), + ).rejects.toThrow("만료"); + }); + + test("제목이 없는 이벤트는 '(제목 없음)'으로 표시한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "no-title", + start: { dateTime: "2026-04-15T10:00:00Z" }, + end: { dateTime: "2026-04-15T11:00:00Z" }, + status: "confirmed", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events[0].summary).toBe("(제목 없음)"); + }); +}); + +describe("createEvent", () => { + test("새 이벤트를 생성한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "new-event-id", + summary: "새 일정", + start: { dateTime: "2026-04-16T14:00:00+09:00" }, + end: { dateTime: "2026-04-16T15:00:00+09:00" }, + status: "confirmed", + htmlLink: "https://calendar.google.com/event?id=new", + }), + }); + + const event = await createEvent("token", "primary", { + summary: "새 일정", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + }); + + expect(event.id).toBe("new-event-id"); + expect(event.summary).toBe("새 일정"); + }); + + test("POST 메서드와 JSON body로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "e", + summary: "test", + start: {}, + end: {}, + status: "confirmed", + }), + }); + + await createEvent("token", "primary", { + summary: "테스트", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + location: "강남역", + description: "설명", + timeZone: "Asia/Seoul", + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/calendars/primary/events"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(options.body); + expect(body.summary).toBe("테스트"); + expect(body.location).toBe("강남역"); + expect(body.description).toBe("설명"); + expect(body.start.timeZone).toBe("Asia/Seoul"); + }); + + test("timeZone 미지정 시 Asia/Seoul을 기본값으로 사용한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "e", + summary: "t", + start: {}, + end: {}, + status: "confirmed", + }), + }); + + await createEvent("token", "primary", { + summary: "기본 타임존", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.start.timeZone).toBe("Asia/Seoul"); + expect(body.end.timeZone).toBe("Asia/Seoul"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect( + createEvent("expired", "primary", { + summary: "실패", + startDateTime: "2026-04-16T14:00:00Z", + endDateTime: "2026-04-16T15:00:00Z", + }), + ).rejects.toThrow("만료"); + }); +}); diff --git a/src/lib/google/auth.ts b/src/lib/google/auth.ts new file mode 100644 index 0000000..5fe70f0 --- /dev/null +++ b/src/lib/google/auth.ts @@ -0,0 +1,184 @@ +import { cookies } from "next/headers"; +import type { GoogleTokens } from "@/types/google-calendar"; + +// ============================================================ +// Google OAuth 2.0 인증 모듈 +// ============================================================ + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +// 캘린더 읽기/쓰기 + 사용자 이메일 조회 +const SCOPES = [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/userinfo.email", +].join(" "); + +const TOKEN_COOKIE_NAME = "google_tokens"; + +function getClientId(): string { + const id = process.env.GOOGLE_CLIENT_ID; + if (!id) throw new Error("GOOGLE_CLIENT_ID 환경변수가 설정되지 않았습니다."); + return id; +} + +function getClientSecret(): string { + const secret = process.env.GOOGLE_CLIENT_SECRET; + if (!secret) + throw new Error("GOOGLE_CLIENT_SECRET 환경변수가 설정되지 않았습니다."); + return secret; +} + +function getRedirectUri(): string { + const base = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; + return `${base}/api/google/callback`; +} + +/** + * Google OAuth 인증 URL을 생성한다. + * 사용자를 이 URL로 리다이렉트하면 Google 동의 화면이 표시된다. + */ +export function buildAuthUrl(state?: string): string { + const params = new URLSearchParams({ + client_id: getClientId(), + redirect_uri: getRedirectUri(), + response_type: "code", + scope: SCOPES, + access_type: "offline", // refresh_token 발급 + prompt: "consent", // 항상 동의 화면 표시 (refresh_token 보장) + }); + if (state) params.set("state", state); + + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +/** + * Authorization code를 access_token + refresh_token으로 교환한다. + */ +export async function exchangeCodeForTokens( + code: string, +): Promise { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: getClientId(), + client_secret: getClientSecret(), + redirect_uri: getRedirectUri(), + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`토큰 교환 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +/** + * refresh_token으로 새 access_token을 발급받는다. + */ +export async function refreshAccessToken( + refreshToken: string, +): Promise<{ accessToken: string; expiresAt: number }> { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + refresh_token: refreshToken, + client_id: getClientId(), + client_secret: getClientSecret(), + grant_type: "refresh_token", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`토큰 갱신 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return { + accessToken: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +/** + * Google 사용자 이메일을 조회한다. + */ +export async function getUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v2/userinfo", + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (!response.ok) throw new Error("사용자 정보 조회 실패"); + + const data = await response.json(); + return data.email; +} + +// ============================================================ +// 쿠키 기반 토큰 저장/조회 +// Supabase DB 연결 전까지 임시 저장소로 사용 +// ============================================================ + +/** 토큰을 HttpOnly 쿠키에 저장한다. */ +export async function saveTokensToCookie(tokens: GoogleTokens): Promise { + const cookieStore = await cookies(); + cookieStore.set(TOKEN_COOKIE_NAME, JSON.stringify(tokens), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 30 * 24 * 60 * 60, // 30일 + }); +} + +/** 쿠키에서 토큰을 읽고, 만료됐으면 자동 갱신한다. */ +export async function getValidTokens(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(TOKEN_COOKIE_NAME)?.value; + if (!raw) return null; + + let tokens: GoogleTokens; + try { + tokens = JSON.parse(raw); + } catch { + return null; + } + + // 만료 5분 전이면 갱신 + if (tokens.expiresAt - Date.now() < 5 * 60 * 1000) { + try { + const refreshed = await refreshAccessToken(tokens.refreshToken); + tokens = { + ...tokens, + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + }; + await saveTokensToCookie(tokens); + } catch { + return null; // 갱신 실패 → 재로그인 필요 + } + } + + return tokens; +} + +/** 토큰 쿠키를 삭제한다. */ +export async function clearTokensCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(TOKEN_COOKIE_NAME); +} diff --git a/src/lib/google/calendars.ts b/src/lib/google/calendars.ts new file mode 100644 index 0000000..b51748b --- /dev/null +++ b/src/lib/google/calendars.ts @@ -0,0 +1,37 @@ +import type { GoogleCalendar } from "@/types/google-calendar"; + +const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"; + +/** + * 사용자의 Google Calendar 목록을 조회한다. + */ +export async function listCalendars( + accessToken: string, +): Promise { + const response = await fetch( + `${CALENDAR_API_BASE}/users/me/calendarList?minAccessRole=writer`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`캘린더 목록 조회 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return (data.items ?? []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any): GoogleCalendar => ({ + id: item.id, + summary: item.summary, + description: item.description, + backgroundColor: item.backgroundColor, + primary: item.primary ?? false, + accessRole: item.accessRole, + }), + ); +} diff --git a/src/lib/google/events.ts b/src/lib/google/events.ts new file mode 100644 index 0000000..aa8efba --- /dev/null +++ b/src/lib/google/events.ts @@ -0,0 +1,109 @@ +import type { GoogleEvent, GoogleEventInput } from "@/types/google-calendar"; + +const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"; + +/** + * 특정 캘린더의 이벤트를 기간별로 조회한다. + */ +export async function queryEvents( + accessToken: string, + calendarId: string, + timeMin: Date, + timeMax: Date, +): Promise { + const params = new URLSearchParams({ + timeMin: timeMin.toISOString(), + timeMax: timeMax.toISOString(), + singleEvents: "true", // 반복 이벤트를 개별 인스턴스로 확장 + orderBy: "startTime", + maxResults: "250", + }); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`일정 조회 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items: any[] = data.items ?? []; + + return items + .filter((item) => item.status !== "cancelled") + .map( + (item): GoogleEvent => ({ + id: item.id, + summary: item.summary ?? "(제목 없음)", + description: item.description, + location: item.location, + start: item.start, + end: item.end, + status: item.status, + htmlLink: item.htmlLink, + }), + ); +} + +/** + * 캘린더에 새 이벤트를 생성한다. + */ +export async function createEvent( + accessToken: string, + calendarId: string, + input: GoogleEventInput, +): Promise { + const body = { + summary: input.summary, + description: input.description, + location: input.location, + start: { + dateTime: input.startDateTime, + timeZone: input.timeZone ?? "Asia/Seoul", + }, + end: { + dateTime: input.endDateTime, + timeZone: input.timeZone ?? "Asia/Seoul", + }, + }; + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`일정 생성 실패 (${response.status}): ${error}`); + } + + const item = await response.json(); + + return { + id: item.id, + summary: item.summary, + description: item.description, + location: item.location, + start: item.start, + end: item.end, + status: item.status, + htmlLink: item.htmlLink, + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/types/google-calendar.ts b/src/types/google-calendar.ts new file mode 100644 index 0000000..0899434 --- /dev/null +++ b/src/types/google-calendar.ts @@ -0,0 +1,72 @@ +// ============================================================ +// Google Calendar 연동 도메인 타입 +// ============================================================ + +/** Google OAuth 토큰 */ +export interface GoogleTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp (ms) +} + +/** Google Calendar 목록 항목 */ +export interface GoogleCalendar { + id: string; + summary: string; + description?: string; + backgroundColor?: string; + primary?: boolean; + accessRole: string; +} + +/** Google Calendar 이벤트 */ +export interface GoogleEvent { + id: string; + summary: string; + description?: string; + location?: string; + start: { + dateTime?: string; // ISO 8601 + date?: string; // YYYY-MM-DD (종일 이벤트) + timeZone?: string; + }; + end: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + status: string; + htmlLink?: string; +} + +/** 일정 생성 입력 */ +export interface GoogleEventInput { + summary: string; + description?: string; + location?: string; + startDateTime: string; // ISO 8601 + endDateTime: string; // ISO 8601 + timeZone?: string; +} + +/** Google Calendar API 에러 응답 */ +export interface GoogleApiError { + error: { + code: number; + message: string; + status: string; + }; +} + +/** DB의 google_connections 행 */ +export interface GoogleConnectionRow { + id: string; + profile_id: string; + google_email: string; + encrypted_refresh_token: string; + encryption_iv: string; + is_active: boolean; + last_synced_at: string | null; + created_at: string; + updated_at: string; +} diff --git a/supabase/migrations/20260415000000_google_connections.sql b/supabase/migrations/20260415000000_google_connections.sql new file mode 100644 index 0000000..6818ce6 --- /dev/null +++ b/supabase/migrations/20260415000000_google_connections.sql @@ -0,0 +1,68 @@ +-- ============================================================ +-- Google Calendar 연동 테이블 +-- ============================================================ + +-- Google OAuth 연결 정보 (refresh_token 암호화 저장) +CREATE TABLE IF NOT EXISTS google_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + google_email TEXT NOT NULL, + + -- refresh_token 암호화 저장 (AES-256-GCM) + encrypted_refresh_token TEXT NOT NULL, + encryption_iv TEXT NOT NULL, + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- 같은 사용자가 같은 Google 계정을 중복 연결하지 않도록 + UNIQUE (profile_id, google_email) +); + +-- updated_at 자동 갱신 트리거 +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_google_connections_updated_at + BEFORE UPDATE ON google_connections + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================================ +-- RLS (Row Level Security) 정책 +-- ============================================================ + +ALTER TABLE google_connections ENABLE ROW LEVEL SECURITY; + +-- 자신의 연결만 조회 가능 +CREATE POLICY google_connections_select_own + ON google_connections FOR SELECT + USING (profile_id = auth.uid()); + +-- 자신의 연결만 삽입 가능 +CREATE POLICY google_connections_insert_own + ON google_connections FOR INSERT + WITH CHECK (profile_id = auth.uid()); + +-- 자신의 연결만 수정 가능 +CREATE POLICY google_connections_update_own + ON google_connections FOR UPDATE + USING (profile_id = auth.uid()); + +-- 자신의 연결만 삭제 가능 +CREATE POLICY google_connections_delete_own + ON google_connections FOR DELETE + USING (profile_id = auth.uid()); + +-- ============================================================ +-- 인덱스 +-- ============================================================ + +CREATE INDEX idx_google_connections_profile_id + ON google_connections(profile_id); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cc8ffa2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + "vitest.config.ts" + ] +}