diff --git a/package-lock.json b/package-lock.json index 65855e9..d3557d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@prisma/client": "^6.19.0", + "@types/nodemailer": "^7.0.4", "bcryptjs": "^2.4.3", "dotenv": "^17.2.3", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "lucide-react": "^0.555.0", "next": "^16.0.3", + "nodemailer": "^7.0.11", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.8" @@ -131,6 +133,713 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.950.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.950.0.tgz", + "integrity": "sha512-GiiaGTtHP+CCCKWZ8Zl5hZvKcgvhAffVtwR/rV9dwWgHIy1Su39xU3tNDeCW160hhKPyDDcCiH1GMDykuzdBAg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/credential-provider-node": "3.948.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/signature-v4-multi-region": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.948.0.tgz", + "integrity": "sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", + "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.947.0.tgz", + "integrity": "sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.947.0.tgz", + "integrity": "sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.948.0.tgz", + "integrity": "sha512-Cl//Qh88e8HBL7yYkJNpF5eq76IO6rq8GsatKcfVBm7RFVxCqYEPSSBtkHdbtNwQdRQqAMXc6E/lEB/CZUDxnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/credential-provider-env": "3.947.0", + "@aws-sdk/credential-provider-http": "3.947.0", + "@aws-sdk/credential-provider-login": "3.948.0", + "@aws-sdk/credential-provider-process": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.948.0.tgz", + "integrity": "sha512-gcKO2b6eeTuZGp3Vvgr/9OxajMrD3W+FZ2FCyJox363ZgMoYJsyNid1vuZrEuAGkx0jvveLXfwiVS0UXyPkgtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.948.0.tgz", + "integrity": "sha512-ep5vRLnrRdcsP17Ef31sNN4g8Nqk/4JBydcUJuFRbGuyQtrZZrVT81UeH2xhz6d0BK6ejafDB9+ZpBjXuWT5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.947.0", + "@aws-sdk/credential-provider-http": "3.947.0", + "@aws-sdk/credential-provider-ini": "3.948.0", + "@aws-sdk/credential-provider-process": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.947.0.tgz", + "integrity": "sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.948.0.tgz", + "integrity": "sha512-gqLhX1L+zb/ZDnnYbILQqJ46j735StfWV5PbDjxRzBKS7GzsiYoaf6MyHseEopmWrez5zl5l6aWzig7UpzSeQQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.948.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/token-providers": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.948.0.tgz", + "integrity": "sha512-MvYQlXVoJyfF3/SmnNzOVEtANRAiJIObEUYYyjTqKZTmcRIVVky0tPuG26XnB8LmTYgtESwJIZJj/Eyyc9WURQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz", + "integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.947.0.tgz", + "integrity": "sha512-DS2tm5YBKhPW2PthrRBDr6eufChbwXe0NjtTZcYDfUCXf0OR+W6cIqyKguwHMJ+IyYdey30AfVw9/Lb5KB8U8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-arn-parser": "3.893.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", + "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.7", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", + "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.947.0.tgz", + "integrity": "sha512-UaYmzoxf9q3mabIA2hc4T6x5YSFUG2BpNjAZ207EA1bnQMiK+d6vZvb83t7dIWL/U1de1sGV19c1C81Jf14rrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.948.0.tgz", + "integrity": "sha512-V487/kM4Teq5dcr1t5K6eoUKuqlGr9FRWL3MIMukMERJXHZvio6kox60FZ/YtciRHRI75u14YUqm2Dzddcu3+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz", + "integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", + "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2397,6 +3106,586 @@ "dev": true, "license": "MIT" }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.18.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", + "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.6", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", + "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.7", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", + "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", + "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.9.10", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", + "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.7", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", + "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", + "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2871,12 +4160,21 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz", + "integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", @@ -3969,6 +5267,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5509,6 +6813,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -7496,6 +8818,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -8991,6 +10322,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -9396,7 +10739,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 31b433b..f7b9615 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@prisma/client": "^6.19.0", + "@types/nodemailer": "^7.0.4", "bcryptjs": "^2.4.3", "dotenv": "^17.2.3", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "lucide-react": "^0.555.0", "next": "^16.0.3", + "nodemailer": "^7.0.11", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.8" @@ -35,9 +37,9 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/jszip": "^3.4.0", "@types/bcryptjs": "^2.4.2", "@types/jsonwebtoken": "^9.0.6", + "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/prisma/migrations/20251214184421_add_email_verification_fields/migration.sql b/prisma/migrations/20251214184421_add_email_verification_fields/migration.sql new file mode 100644 index 0000000..a590b88 --- /dev/null +++ b/prisma/migrations/20251214184421_add_email_verification_fields/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - A unique constraint covering the columns `[emailVerifyToken]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "emailVerifyToken" TEXT, +ADD COLUMN "emailVerifyTokenExpiresAt" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "User_emailVerifyToken_key" ON "User"("emailVerifyToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5785d1e..00bd5ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,9 @@ model User { email String @unique passwordHash String tokenVersion Int @default(0) + emailVerified Boolean @default(false) + emailVerifyToken String? @unique + emailVerifyTokenExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/auth/change-email/route.ts b/src/app/api/auth/change-email/route.ts new file mode 100644 index 0000000..6b7c139 --- /dev/null +++ b/src/app/api/auth/change-email/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +import { verifyPassword, verifyAccessToken } from "@/features/auth/authCrypto"; + +// /api/auth/change-email +// - pb_access JWT 쿠키로 인증된 사용자의 이메일을 변경한다. +// - 현재 비밀번호를 재입력 받아 검증한 뒤, 아직 사용되지 않은 새 이메일로 교체한다. + +const prisma = new PrismaClient(); + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +export async function POST(request: Request) { + try { + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + + if (!token) { + return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 }); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as any; + const newEmailRaw: string | undefined = body?.newEmail; + const password: string | undefined = body?.password; + + if (typeof newEmailRaw !== "string" || typeof password !== "string") { + return NextResponse.json({ message: "newEmail 과 password 는 필수입니다." }, { status: 400 }); + } + + const newEmail = newEmailRaw.trim().toLowerCase(); + if (!newEmail || !newEmail.includes("@")) { + return NextResponse.json({ message: "유효한 이메일 주소를 입력해주세요." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) { + return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 }); + } + + const ok = await verifyPassword(password, (user as any).passwordHash); + if (!ok) { + return NextResponse.json({ message: "비밀번호가 일치하지 않습니다." }, { status: 401 }); + } + + // 이미 다른 유저가 사용 중인 이메일인지 확인한다. + const existing = await prisma.user.findUnique({ where: { email: newEmail } }); + if (existing && existing.id !== user.id) { + return NextResponse.json({ message: "이미 사용 중인 이메일입니다." }, { status: 409 }); + } + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + email: newEmail, + }, + }); + + return NextResponse.json( + { message: "이메일이 변경되었습니다.", email: (updated as any).email }, + { status: 200 }, + ); + } catch (error: any) { + return NextResponse.json( + { message: "이메일 변경 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..6f84cc5 --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +import { verifyPassword, hashPassword, signAccessToken, verifyAccessToken } from "@/features/auth/authCrypto"; + +// /api/auth/change-password +// - pb_access JWT 쿠키로 인증된 사용자의 비밀번호를 변경한다. +// - 현재 비밀번호를 한 번 더 입력받아 검증한 뒤, 새 비밀번호로 passwordHash 를 교체하고 tokenVersion 을 증가시킨다. + +const prisma = new PrismaClient(); + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +export async function POST(request: Request) { + try { + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + + if (!token) { + return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 }); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as any; + const currentPassword: string | undefined = body?.currentPassword; + const newPassword: string | undefined = body?.newPassword; + + if (typeof currentPassword !== "string" || typeof newPassword !== "string") { + return NextResponse.json({ message: "currentPassword 와 newPassword 는 필수입니다." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) { + return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 }); + } + + const ok = await verifyPassword(currentPassword, (user as any).passwordHash); + if (!ok) { + return NextResponse.json({ message: "현재 비밀번호가 일치하지 않습니다." }, { status: 401 }); + } + + if (newPassword.length < 8) { + return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 }); + } + + const newHash = await hashPassword(newPassword); + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: newHash, + tokenVersion: (user as any).tokenVersion + 1, + }, + }); + + const newToken = await signAccessToken({ + id: updated.id, + email: (updated as any).email, + tokenVersion: (updated as any).tokenVersion, + emailVerified: (updated as any).emailVerified ?? false, + }); + + const res = NextResponse.json({ message: "비밀번호가 변경되었습니다." }, { status: 200 }); + + res.cookies.set("pb_access", newToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 7 * 24 * 60 * 60, + }); + + return res; + } catch (error: any) { + return NextResponse.json( + { message: "비밀번호 변경 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8da750a..dc02cb5 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -32,7 +32,12 @@ export async function POST(request: Request) { return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 }); } - const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion }); + const token = await signAccessToken({ + id: user.id, + email: user.email, + tokenVersion: user.tokenVersion, + emailVerified: (user as any).emailVerified ?? false, + }); const { passwordHash: _ph, ...safeUser } = user; diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 86e95d3..83e0c2a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,10 +1,14 @@ import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; import { verifyAccessToken } from "@/features/auth/authCrypto"; // /api/auth/me // - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고, -// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다. +// - 유효한 경우 JWT 의 sub(id) 를 기준으로 실제 DB(User) 에서 최신 유저 정보를 조회해 반환한다. +// - 이렇게 하면 emailVerified 와 같은 필드가 브라우저/세션에 관계없이 항상 DB 기준으로 일관되게 반영된다. + +const prisma = new PrismaClient(); function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { if (!cookieHeader) return null; @@ -34,10 +38,18 @@ export async function GET(request: Request) { return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); } + // JWT 에서 식별한 ID 를 기준으로 실제 User 를 조회해 최신 정보를 사용한다. + const dbUser = await prisma.user.findUnique({ where: { id: payload.sub } }); + + if (!dbUser) { + return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 }); + } + const user = { - id: payload.sub, - email: payload.email, - tokenVersion: payload.tokenVersion, + id: dbUser.id, + email: dbUser.email, + tokenVersion: (dbUser as any).tokenVersion, + emailVerified: (dbUser as any).emailVerified ?? false, }; return NextResponse.json(user, { status: 200 }); diff --git a/src/app/api/auth/request-email-verification/route.ts b/src/app/api/auth/request-email-verification/route.ts new file mode 100644 index 0000000..549267a --- /dev/null +++ b/src/app/api/auth/request-email-verification/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; +import { randomBytes } from "crypto"; + +import { verifyAccessToken } from "@/features/auth/authCrypto"; +import { sendVerificationEmail } from "@/features/email/sendVerificationEmail"; + +// /api/auth/request-email-verification +// - pb_access JWT 쿠키로 인증된 사용자의 이메일 인증 토큰을 새로 발급한다. +// - 개발 모드에서는 verificationToken 을 응답 바디에 포함해 테스트에서 사용할 수 있도록 한다. + +const prisma = new PrismaClient(); + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +export async function POST(request: Request) { + try { + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + + if (!token) { + return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 }); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) { + return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 }); + } + + const verificationToken = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerifyToken: verificationToken, + emailVerifyTokenExpiresAt: expiresAt, + }, + }); + + // 이메일 인증 링크를 Gmail SMTP 를 통해 전송한다. + await sendVerificationEmail({ + to: (user as any).email, + token: verificationToken, + }); + + return NextResponse.json( + { + message: "이메일 인증 토큰이 생성되었습니다.", + verificationToken, + }, + { status: 200 }, + ); + } catch (error: any) { + return NextResponse.json( + { message: "이메일 인증 토큰 생성 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 9c4b72e..8dc1b4a 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -47,6 +47,7 @@ export async function POST(request: Request) { id: user.id, email: user.email, tokenVersion: user.tokenVersion, + emailVerified: (user as any).emailVerified ?? false, }); const { passwordHash: _ph, ...safeUser } = user; diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..41ccaa6 --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +import { signAccessToken } from "@/features/auth/authCrypto"; + +// /api/auth/verify-email +// - 이메일 인증 토큰을 검증하고, 성공 시 emailVerified 를 true 로 설정한다. +// - 토큰이 없거나 잘못되었거나 만료된 경우 400 을 반환한다. + +const prisma = new PrismaClient(); + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const token = url.searchParams.get("token"); + + if (!token) { + return NextResponse.json({ message: "이메일 인증 토큰이 없습니다." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { emailVerifyToken: token } }); + + if (!user) { + return NextResponse.json({ message: "유효하지 않은 이메일 인증 토큰입니다." }, { status: 400 }); + } + + const expiresAt = (user as any).emailVerifyTokenExpiresAt as Date | null; + if (!expiresAt || expiresAt.getTime() < Date.now()) { + return NextResponse.json({ message: "이메일 인증 토큰이 만료되었거나 유효하지 않습니다." }, { status: 400 }); + } + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + emailVerifyToken: null, + emailVerifyTokenExpiresAt: null, + }, + }); + + const newToken = await signAccessToken({ + id: updated.id, + email: (updated as any).email, + tokenVersion: (updated as any).tokenVersion, + emailVerified: true, + }); + + // 인증 완료 후 마이페이지로 리다이렉트하면서, pb_access 쿠키도 갱신한다. + const redirectUrl = new URL("/mypage", request.url); + const res = NextResponse.redirect(redirectUrl); + res.cookies.set("pb_access", newToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 7 * 24 * 60 * 60, + }); + return res; + } catch (error: any) { + return NextResponse.json( + { message: "이메일 인증 처리 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/reports/leads/route.ts b/src/app/api/reports/leads/route.ts new file mode 100644 index 0000000..f4a773b --- /dev/null +++ b/src/app/api/reports/leads/route.ts @@ -0,0 +1,169 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +import { verifyAccessToken } from "@/features/auth/authCrypto"; + +// /api/reports/leads +// - 로그인한 사용자의 FormSubmission(리드) 목록을 기간/프로젝트 필터에 맞게 조회해, +// 최신순으로 정렬된 리드 리스트와 총 개수를 반환한다. + +const prisma = new PrismaClient(); + +type RangeParam = "7d" | "30d" | "all"; + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const range = (url.searchParams.get("range") as RangeParam | null) ?? "all"; + const projectIdFilter = url.searchParams.get("projectId"); + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); + + if (range !== "7d" && range !== "30d" && range !== "all") { + return NextResponse.json({ message: "Invalid range parameter" }, { status: 400 }); + } + + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + + if (!token) { + return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 }); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); + } + + const userId = payload.sub; + + const [projects, submissions] = await Promise.all([ + prisma.project.findMany({ + where: { userId }, + select: { + id: true, + title: true, + slug: true, + }, + }), + prisma.formSubmission.findMany({ + where: { userId }, + select: { + id: true, + projectId: true, + projectSlug: true, + createdAt: true, + payloadJson: true, + }, + }), + ]); + + let fromDate: Date | null = null; + let toDate: Date | null = null; + + if (fromParam || toParam) { + if (fromParam) { + const parsedFrom = new Date(`${fromParam}T00:00:00.000Z`); + if (Number.isNaN(parsedFrom.getTime())) { + return NextResponse.json({ message: "Invalid from parameter" }, { status: 400 }); + } + fromDate = parsedFrom; + } + if (toParam) { + const parsedTo = new Date(`${toParam}T23:59:59.999Z`); + if (Number.isNaN(parsedTo.getTime())) { + return NextResponse.json({ message: "Invalid to parameter" }, { status: 400 }); + } + toDate = parsedTo; + } + } else { + const now = new Date(); + if (range === "7d") { + fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + } else if (range === "30d") { + fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + } + } + + // 1단계: 기간/프로젝트 필터 적용 + const filtered = submissions.filter((s) => { + const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + + if (fromDate && createdAt < fromDate) { + return false; + } + + if (toDate && createdAt > toDate) { + return false; + } + + if (projectIdFilter && s.projectId !== projectIdFilter) { + return false; + } + + return true; + }); + + // 2단계: 최신순 정렬 (createdAt desc) + filtered.sort((a, b) => { + const aDate = a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt as any); + const bDate = b.createdAt instanceof Date ? b.createdAt : new Date(b.createdAt as any); + return bDate.getTime() - aDate.getTime(); + }); + + const projectById = new Map< + string, + { + title: string; + slug: string; + } + >(); + + for (const p of projects) { + projectById.set(p.id, { title: p.title, slug: p.slug }); + } + + const items = filtered.map((s) => { + const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + const projectMeta = s.projectId ? projectById.get(s.projectId) : null; + + return { + id: s.id, + projectId: s.projectId ?? null, + projectSlug: s.projectSlug, + projectTitle: projectMeta?.title ?? null, + createdAt: createdAt.toISOString(), + payload: (s as any).payloadJson ?? {}, + }; + }); + + const body = { + total: items.length, + items, + }; + + return NextResponse.json(body, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { + message: "리드 리포트를 불러오는 중 오류가 발생했습니다.", + error: error?.message, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/reports/overview/route.ts b/src/app/api/reports/overview/route.ts new file mode 100644 index 0000000..f9076fd --- /dev/null +++ b/src/app/api/reports/overview/route.ts @@ -0,0 +1,340 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +import { verifyAccessToken } from "@/features/auth/authCrypto"; + +// /api/reports/overview +// - 로그인한 사용자의 프로젝트/폼 제출 데이터를 기간/프로젝트 필터에 맞게 집계해 리포트용 요약/일별/프로젝트별 통계를 반환한다. + +const prisma = new PrismaClient(); + +type RangeParam = "7d" | "30d" | "all"; + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const range = (url.searchParams.get("range") as RangeParam | null) ?? "all"; + const projectIdFilter = url.searchParams.get("projectId"); + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); + + if (range !== "7d" && range !== "30d" && range !== "all") { + return NextResponse.json({ message: "Invalid range parameter" }, { status: 400 }); + } + + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + + if (!token) { + return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 }); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 }); + } + + const userId = payload.sub; + + const [projects, submissions] = await Promise.all([ + prisma.project.findMany({ + where: { userId }, + select: { + id: true, + title: true, + slug: true, + status: true, + }, + }), + prisma.formSubmission.findMany({ + where: { userId }, + select: { + id: true, + projectId: true, + projectSlug: true, + createdAt: true, + }, + }), + ]); + + let fromDate: Date | null = null; + let toDate: Date | null = null; + + if (fromParam || toParam) { + if (fromParam) { + const parsedFrom = new Date(`${fromParam}T00:00:00.000Z`); + if (Number.isNaN(parsedFrom.getTime())) { + return NextResponse.json({ message: "Invalid from parameter" }, { status: 400 }); + } + fromDate = parsedFrom; + } + if (toParam) { + const parsedTo = new Date(`${toParam}T23:59:59.999Z`); + if (Number.isNaN(parsedTo.getTime())) { + return NextResponse.json({ message: "Invalid to parameter" }, { status: 400 }); + } + toDate = parsedTo; + } + } else { + const now = new Date(); + if (range === "7d") { + fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + } else if (range === "30d") { + fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + } + } + + // 1단계: 기간/프로젝트 필터가 적용된 제출 목록을 만든다. + const filteredSubmissions = submissions.filter((s) => { + const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + + if (fromDate && createdAt < fromDate) { + return false; + } + + if (toDate && createdAt > toDate) { + return false; + } + + if (projectIdFilter && s.projectId !== projectIdFilter) { + return false; + } + + return true; + }); + + const totalSubmissions = filteredSubmissions.length; + + // uniqueProjects: 필터된 제출에서 등장하는 프로젝트 수 + const projectIdsSet = new Set(); + for (const s of filteredSubmissions) { + if (s.projectId) { + projectIdsSet.add(s.projectId); + } + } + const uniqueProjects = projectIdsSet.size; + + // avgPerDay: 최소 1일 기준으로 일평균 제출 수를 계산한다. + let avgPerDay = 0; + if (totalSubmissions > 0) { + // all 인 경우에는 제출이 존재하는 기간(min~max)을 기준으로 일수를 계산하고, + // 7d/30d 인 경우에는 고정 윈도우 길이를 사용한다. + let days = 1; + if (range === "all") { + let minDate = new Date(filteredSubmissions[0].createdAt as any); + let maxDate = minDate; + for (const s of filteredSubmissions) { + const d = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + if (d < minDate) minDate = d; + if (d > maxDate) maxDate = d; + } + const diffMs = maxDate.getTime() - minDate.getTime(); + days = Math.max(1, Math.floor(diffMs / (24 * 60 * 60 * 1000)) + 1); + } else if (range === "7d") { + days = 7; + } else if (range === "30d") { + days = 30; + } + avgPerDay = totalSubmissions / days; + } + + // daily: 일별 제출 수 집계 + const countsByDate = new Map(); + for (const s of filteredSubmissions) { + const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD + const prev = countsByDate.get(dateKey) ?? 0; + countsByDate.set(dateKey, prev + 1); + } + + const daily = Array.from(countsByDate.entries()) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + // byProject: 프로젝트별 제출 수/비율 집계 (제출이 1건 이상인 프로젝트만 포함) + const statsByProjectId = new Map< + string, + { + projectId: string; + title: string; + slug: string; + submissions: number; + } + >(); + + for (const project of projects) { + if (projectIdFilter && project.id !== projectIdFilter) { + continue; + } + statsByProjectId.set(project.id, { + projectId: project.id, + title: project.title, + slug: project.slug, + submissions: 0, + }); + } + + for (const s of filteredSubmissions) { + if (!s.projectId) continue; + const stat = statsByProjectId.get(s.projectId); + if (!stat) continue; + stat.submissions += 1; + } + + const byProjectRaw = Array.from(statsByProjectId.values()).filter((item) => item.submissions > 0); + + const byProject = byProjectRaw.map((item) => ({ + ...item, + ratio: totalSubmissions > 0 ? item.submissions / totalSubmissions : 0, + })); + + // timePatterns: 주/월/요일/시간대별 제출 수 집계 + const countsByWeek = new Map(); + const countsByMonth = new Map(); + const countsByWeekday = new Map(); + const countsByHour = new Map(); + + function getIsoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const day = d.getUTCDay() || 7; // 1~7 (Mon..Sun) + d.setUTCDate(d.getUTCDate() + 4 - day); // nearest Thursday + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d.getTime() - yearStart.getTime()) / (24 * 60 * 60 * 1000) + 1) / 7); + const year = d.getUTCFullYear(); + return `${year}-W${String(week).padStart(2, "0")}`; + } + + for (const s of filteredSubmissions) { + const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any); + + const monthKey = `${createdAt.getUTCFullYear()}-${String(createdAt.getUTCMonth() + 1).padStart(2, "0")}`; + const weekKey = getIsoWeekKey(createdAt); + const weekday = createdAt.getUTCDay(); // 0~6 + const hour = createdAt.getUTCHours(); // 0~23 + + countsByMonth.set(monthKey, (countsByMonth.get(monthKey) ?? 0) + 1); + countsByWeek.set(weekKey, (countsByWeek.get(weekKey) ?? 0) + 1); + countsByWeekday.set(weekday, (countsByWeekday.get(weekday) ?? 0) + 1); + countsByHour.set(hour, (countsByHour.get(hour) ?? 0) + 1); + } + + const timePatterns = { + byWeek: Array.from(countsByWeek.entries()) + .map(([week, count]) => ({ week, count })) + .sort((a, b) => a.week.localeCompare(b.week)), + byMonth: Array.from(countsByMonth.entries()) + .map(([month, count]) => ({ month, count })) + .sort((a, b) => a.month.localeCompare(b.month)), + byWeekday: Array.from(countsByWeekday.entries()) + .map(([weekday, count]) => ({ weekday, count })) + .sort((a, b) => a.weekday - b.weekday), + byHour: Array.from(countsByHour.entries()) + .map(([hour, count]) => ({ hour, count })) + .sort((a, b) => a.hour - b.hour), + }; + + // projectStatusSummary: 상태별 프로젝트 수/제출 수 집계 + const projectStatusById = new Map< + string, + { + status: string; + } + >(); + + for (const project of projects) { + if (projectIdFilter && project.id !== projectIdFilter) { + continue; + } + projectStatusById.set(project.id, { + status: (project as any).status ?? "UNKNOWN", + }); + } + + const statusStatsMap = new Map< + string, + { + status: string; + projectIds: Set; + submissions: number; + } + >(); + + function ensureStatusEntry(statusKey: string): { + status: string; + projectIds: Set; + submissions: number; + } { + let entry = statusStatsMap.get(statusKey); + if (!entry) { + entry = { + status: statusKey, + projectIds: new Set(), + submissions: 0, + }; + statusStatsMap.set(statusKey, entry); + } + return entry; + } + + // 프로젝트마다 상태 기반 프로젝트 수 초기화 + for (const [projectId, meta] of projectStatusById.entries()) { + const entry = ensureStatusEntry(meta.status); + entry.projectIds.add(projectId); + } + + // 제출 건수를 상태별로 합산 + for (const s of filteredSubmissions) { + if (!s.projectId) continue; + const meta = projectStatusById.get(s.projectId); + const statusKey = meta?.status ?? "UNKNOWN"; + const entry = ensureStatusEntry(statusKey); + entry.submissions += 1; + } + + const projectStatusSummary = { + byStatus: Array.from(statusStatsMap.values()) + .map((entry) => ({ + status: entry.status, + projects: entry.projectIds.size, + submissions: entry.submissions, + })) + .sort((a, b) => a.status.localeCompare(b.status)), + }; + + const body = { + summary: { + totalSubmissions, + uniqueProjects, + avgPerDay, + }, + daily, + byProject, + timePatterns, + projectStatusSummary, + }; + + return NextResponse.json(body, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { + message: "리포트 데이터를 계산하는 중 오류가 발생했습니다.", + error: error?.message, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 8f44c06..e3b8054 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react"; +import { BarChart2, FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react"; import { useAppLocale } from "@/features/i18n/LocaleProvider"; import { getDashboardMessages } from "@/features/i18n/messages/dashboard"; @@ -46,6 +46,7 @@ export default function DashboardPage() { const [status, setStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [emailVerified, setEmailVerified] = useState(null); // 마운트 시 대시보드 개요 데이터를 불러온다. useEffect(() => { @@ -103,6 +104,31 @@ export default function DashboardPage() { }; }, [router]); + // 마운트 시 현재 사용자 이메일 인증 여부를 확인한다. + useEffect(() => { + let cancelled = false; + + const loadMe = async () => { + try { + const res = await fetch("/api/auth/me"); + if (cancelled || !res.ok) return; + + const data = (await res.json().catch(() => null)) as any; + if (!cancelled && data) { + setEmailVerified(Boolean(data.emailVerified)); + } + } catch { + // ignore – 인증 여부를 알 수 없으면 배너만 표시하지 않는다. + } + }; + + void loadMe(); + + return () => { + cancelled = true; + }; + }, []); + const safeSummary: SummaryStats = summary ?? { totalProjects: 0, @@ -170,6 +196,13 @@ export default function DashboardPage() {