From d943561e8938424325ee46824eea971234fc4861 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 00:43:24 +0000 Subject: [PATCH] verification emails and full email setup --- bun.lock | 156 +- packages/backend/.env.example | 5 +- .../backend/drizzle/0028_quick_supernaut.sql | 28 + .../backend/drizzle/meta/0028_snapshot.json | 1354 +++++++++++++++++ packages/backend/drizzle/meta/_journal.json | 7 + packages/backend/package.json | 7 + .../src/db/queries/email-verification.ts | 121 ++ packages/backend/src/db/queries/index.ts | 1 + packages/backend/src/emails/index.ts | 1 + .../src/emails/templates/VerificationCode.tsx | 3 + packages/backend/src/index.ts | 2 + packages/backend/src/lib/email/service.ts | 54 + packages/backend/src/routes/auth/login.ts | 1 + packages/backend/src/routes/auth/me.ts | 1 + packages/backend/src/routes/auth/register.ts | 18 +- .../src/routes/auth/resend-verification.ts | 69 + .../backend/src/routes/auth/verify-email.ts | 32 + packages/backend/src/routes/index.ts | 4 + packages/frontend/package.json | 29 +- .../frontend/src/components/login-form.tsx | 11 +- .../frontend/src/components/login-modal.tsx | 6 +- .../src/components/session-provider.tsx | 35 +- .../frontend/src/components/ui/input-otp.tsx | 69 + .../frontend/src/components/upload-avatar.tsx | 6 +- .../src/components/verification-modal.tsx | 104 ++ .../frontend/src/lib/query/hooks/index.ts | 1 + .../src/lib/query/hooks/verification.ts | 22 + packages/shared/src/api-schemas.ts | 9 + packages/shared/src/contract.ts | 24 + packages/shared/src/index.ts | 12 + packages/shared/src/schema.ts | 51 +- 31 files changed, 2190 insertions(+), 53 deletions(-) create mode 100644 packages/backend/drizzle/0028_quick_supernaut.sql create mode 100644 packages/backend/drizzle/meta/0028_snapshot.json create mode 100644 packages/backend/src/db/queries/email-verification.ts create mode 100644 packages/backend/src/emails/index.ts create mode 100644 packages/backend/src/emails/templates/VerificationCode.tsx create mode 100644 packages/backend/src/lib/email/service.ts create mode 100644 packages/backend/src/routes/auth/resend-verification.ts create mode 100644 packages/backend/src/routes/auth/verify-email.ts create mode 100644 packages/frontend/src/components/ui/input-otp.tsx create mode 100644 packages/frontend/src/components/verification-modal.tsx create mode 100644 packages/frontend/src/lib/query/hooks/verification.ts diff --git a/bun.lock b/bun.lock index 3657e1f..e26f82d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,12 +13,17 @@ "name": "@sprint/backend", "version": "0.1.0", "dependencies": { + "@react-email/components": "^1.0.6", + "@react-email/render": "^2.0.4", "@sprint/shared": "workspace:*", "bcrypt": "^6.0.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.0", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "resend": "^6.9.1", "sharp": "^0.34.5", "stripe": "^20.2.0", "zod": "^3.23.8", @@ -28,6 +33,8 @@ "@types/bun": "latest", "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.15.6", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.8", "tsx": "^4.21.0", }, @@ -62,12 +69,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "input-otp": "^1.4.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", - "react": "^19.1.0", + "react": "19.2.4", "react-colorful": "^5.6.1", "react-day-picker": "^9.13.0", - "react-dom": "^19.1.0", + "react-dom": "19.2.4", "react-resizable-panels": "^4.0.15", "react-router-dom": "^7.10.1", "sonner": "^2.0.7", @@ -350,6 +358,48 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-email/body": ["@react-email/body@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ=="], + + "@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="], + + "@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="], + + "@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="], + + "@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="], + + "@react-email/components": ["@react-email/components@1.0.6", "", { "dependencies": { "@react-email/body": "0.2.1", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.4", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.3", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-3GwOeq+5yyiAcwSf7TnHi/HWKn22lXbwxQmkkAviSwZLlhsRVxvmWqRxvUVfQk/HclDUG+62+sGz9qjfb2Uxjw=="], + + "@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="], + + "@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="], + + "@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="], + + "@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="], + + "@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="], + + "@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="], + + "@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="], + + "@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="], + + "@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="], + + "@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="], + + "@react-email/render": ["@react-email/render@2.0.4", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g=="], + + "@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="], + + "@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="], + + "@react-email/tailwind": ["@react-email/tailwind@2.0.3", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": "0.2.1", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/container": "0.0.16", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/preview": "0.0.14", "@react-email/text": "0.1.6", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-URXb/T2WS4RlNGM5QwekYnivuiVUcU87H0y5sqLl6/Oi3bMmgL0Bmw/W9GeJylC+876Vw+E6NkE0uRiUFIQwGg=="], + + "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], @@ -402,12 +452,16 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sprint/backend": ["@sprint/backend@workspace:packages/backend"], "@sprint/frontend": ["@sprint/frontend@workspace:packages/frontend"], "@sprint/shared": ["@sprint/shared@workspace:packages/shared"], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -438,11 +492,11 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.19", "", { "dependencies": { "@tanstack/query-core": "5.90.19" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="], @@ -494,16 +548,18 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], - "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], + "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -554,10 +610,20 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], @@ -574,8 +640,12 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -588,6 +658,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -616,6 +688,16 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -632,6 +714,14 @@ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + + "libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="], + + "libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="], + + "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -656,6 +746,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], @@ -676,6 +768,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mailparser": ["mailparser@3.9.1", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.11", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA=="], + + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -690,8 +786,14 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "nodemailer": ["nodemailer@7.0.11", "", {}, "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], + + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "pg": ["pg@8.17.1", "", { "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], @@ -722,15 +824,21 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], - "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], "react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], - "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -738,16 +846,18 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable-panels": ["react-resizable-panels@4.4.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ=="], + "react-resizable-panels": ["react-resizable-panels@4.5.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-N4Uc13yM1S9nfmwH25WIpxlp4/cwh2rqj9bDSxyZJ3S6gOJ9kFsZnPalfqIeBrbUv2SoGVLAbQUaTFceUY7A5Q=="], - "react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], + "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], - "react-router-dom": ["react-router-dom@7.12.0", "", { "dependencies": { "react-router": "7.12.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA=="], + "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resend": ["resend@6.9.1", "", { "dependencies": { "mailparser": "3.9.1", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], @@ -756,8 +866,12 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -784,6 +898,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -792,6 +908,8 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -800,6 +918,8 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -810,6 +930,8 @@ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -818,6 +940,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -870,8 +994,18 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/bcrypt/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "@types/jsonwebtoken/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "@types/pg/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "bun-types/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 00fb588..2e28ed5 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -13,4 +13,7 @@ S3_SECRET_ACCESS_KEY=your_secret_access_key S3_BUCKET_NAME=issue STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key -STRIPE_SECRET_KEY=your_stripe_secret_key \ No newline at end of file +STRIPE_SECRET_KEY=your_stripe_secret_key + +RESEND_API_KEY=re_xxxxxxxxxxxxxxxx +EMAIL_FROM=Sprint \ No newline at end of file diff --git a/packages/backend/drizzle/0028_quick_supernaut.sql b/packages/backend/drizzle/0028_quick_supernaut.sql new file mode 100644 index 0000000..dfd5aba --- /dev/null +++ b/packages/backend/drizzle/0028_quick_supernaut.sql @@ -0,0 +1,28 @@ +CREATE TABLE "EmailJob" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailJob_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "userId" integer NOT NULL, + "type" varchar(64) NOT NULL, + "scheduledFor" timestamp NOT NULL, + "sentAt" timestamp, + "failedAt" timestamp, + "errorMessage" text, + "metadata" json, + "createdAt" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "EmailVerification" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailVerification_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "userId" integer NOT NULL, + "code" varchar(6) NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 5 NOT NULL, + "expiresAt" timestamp NOT NULL, + "verifiedAt" timestamp, + "createdAt" timestamp DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "User" ALTER COLUMN "email" SET DATA TYPE varchar(256);--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "emailVerified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" timestamp;--> statement-breakpoint +ALTER TABLE "EmailJob" ADD CONSTRAINT "EmailJob_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "EmailVerification" ADD CONSTRAINT "EmailVerification_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0028_snapshot.json b/packages/backend/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..68abde6 --- /dev/null +++ b/packages/backend/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1354 @@ +{ + "id": "4e8f597a-39c9-47c6-9eb4-a085a88bc1b5", + "prevId": "b826ec09-e4ac-49b1-9975-b36f5be69b0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.EmailJob": { + "name": "EmailJob", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "EmailJob_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAt": { + "name": "failedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "EmailJob_userId_User_id_fk": { + "name": "EmailJob_userId_User_id_fk", + "tableFrom": "EmailJob", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmailVerification": { + "name": "EmailVerification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "EmailVerification_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "EmailVerification_userId_User_id_fk": { + "name": "EmailVerification_userId_User_id_fk", + "tableFrom": "EmailVerification", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailVerifiedAt": { + "name": "emailVerifiedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 7ee5a8f..a2bcbb9 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1769635016079, "tag": "0027_volatile_otto_octavius", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1769643481882, + "tag": "0028_quick_supernaut", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index 91cc33d..7a072aa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -20,6 +20,8 @@ "@types/bun": "latest", "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.15.6", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.8", "tsx": "^4.21.0" }, @@ -27,12 +29,17 @@ "typescript": "^5" }, "dependencies": { + "@react-email/components": "^1.0.6", + "@react-email/render": "^2.0.4", "@sprint/shared": "workspace:*", "bcrypt": "^6.0.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.0", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "resend": "^6.9.1", "sharp": "^0.34.5", "stripe": "^20.2.0", "zod": "^3.23.8" diff --git a/packages/backend/src/db/queries/email-verification.ts b/packages/backend/src/db/queries/email-verification.ts new file mode 100644 index 0000000..21528da --- /dev/null +++ b/packages/backend/src/db/queries/email-verification.ts @@ -0,0 +1,121 @@ +import { EmailVerification, type EmailVerificationRecord, User } from "@sprint/shared"; +import { eq, lt, sql } from "drizzle-orm"; +import { db } from "../client"; + +const CODE_EXPIRY_MINUTES = 15; +const MAX_ATTEMPTS = 5; + +export function generateVerificationCode(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + + // 6 digit + const code = ((bytes[0] ?? 0) * 256 * 256 + (bytes[1] ?? 0) * 256 + (bytes[2] ?? 0)) % 1000000; + return code.toString().padStart(6, "0"); +} + +export async function createVerificationCode(userId: number): Promise { + const code = generateVerificationCode(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000); + + // delete existing codes for the user + await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId)); + + const [verification] = await db + .insert(EmailVerification) + .values({ + userId, + code, + expiresAt, + attempts: 0, + maxAttempts: MAX_ATTEMPTS, + }) + .returning(); + + if (!verification) { + throw new Error("Failed to create verification code"); + } + + return verification; +} + +export async function getVerificationByUserId(userId: number): Promise { + const [verification] = await db + .select() + .from(EmailVerification) + .where(eq(EmailVerification.userId, userId)); + return verification; +} + +export async function incrementAttempts(id: number): Promise { + await db + .update(EmailVerification) + .set({ + attempts: sql`CASE WHEN ${EmailVerification.attempts} IS NULL THEN 1 ELSE ${EmailVerification.attempts} + 1 END`, + }) + .where(eq(EmailVerification.id, id)); +} + +export async function markAsVerified(id: number): Promise { + await db.update(EmailVerification).set({ verifiedAt: new Date() }).where(eq(EmailVerification.id, id)); +} + +export async function deleteVerification(id: number): Promise { + await db.delete(EmailVerification).where(eq(EmailVerification.id, id)); +} + +export async function deleteUserVerifications(userId: number): Promise { + await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId)); +} + +export async function cleanupExpiredVerifications(): Promise { + const result = await db.delete(EmailVerification).where(lt(EmailVerification.expiresAt, new Date())); + return result.rowCount ?? 0; +} + +export async function verifyCode( + userId: number, + code: string, +): Promise<{ success: boolean; error?: string }> { + const verification = await getVerificationByUserId(userId); + + if (!verification) { + return { success: false, error: "No verification code found" }; + } + + if (verification.verifiedAt) { + return { success: false, error: "Email already verified" }; + } + + if (new Date() > verification.expiresAt) { + await deleteVerification(verification.id); + return { success: false, error: "Verification code expired" }; + } + + if (verification.attempts >= verification.maxAttempts) { + await deleteVerification(verification.id); + return { success: false, error: "Too many attempts. Please request a new code." }; + } + + if (verification.code !== code) { + await db + .update(EmailVerification) + .set({ attempts: verification.attempts + 1 }) + .where(eq(EmailVerification.id, verification.id)); + + const remainingAttempts = verification.maxAttempts - (verification.attempts + 1); + return { + success: false, + error: `Invalid code. ${remainingAttempts} attempts remaining.`, + }; + } + + await db + .update(User) + .set({ emailVerified: true, emailVerifiedAt: new Date() }) + .where(eq(User.id, userId)); + + await deleteVerification(verification.id); + + return { success: true }; +} diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index b61f22f..c9f3a96 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -1,3 +1,4 @@ +export * from "./email-verification"; export * from "./issue-comments"; export * from "./issues"; export * from "./organisations"; diff --git a/packages/backend/src/emails/index.ts b/packages/backend/src/emails/index.ts new file mode 100644 index 0000000..7e11cd6 --- /dev/null +++ b/packages/backend/src/emails/index.ts @@ -0,0 +1 @@ +export { VerificationCode } from "./templates/VerificationCode"; diff --git a/packages/backend/src/emails/templates/VerificationCode.tsx b/packages/backend/src/emails/templates/VerificationCode.tsx new file mode 100644 index 0000000..47959ad --- /dev/null +++ b/packages/backend/src/emails/templates/VerificationCode.tsx @@ -0,0 +1,3 @@ +export function VerificationCode({ code }: { code: string }) { + return {code}; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1d37a40..a906004 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -41,6 +41,8 @@ const main = async () => { "/auth/login": withGlobal(routes.authLogin), "/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))), "/auth/me": withGlobalAuthed(withAuth(routes.authMe)), + "/auth/verify-email": withGlobalAuthed(withAuth(withCSRF(routes.authVerifyEmail))), + "/auth/resend-verification": withGlobalAuthed(withAuth(withCSRF(routes.authResendVerification))), "/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)), "/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))), diff --git a/packages/backend/src/lib/email/service.ts b/packages/backend/src/lib/email/service.ts new file mode 100644 index 0000000..1a57b61 --- /dev/null +++ b/packages/backend/src/lib/email/service.ts @@ -0,0 +1,54 @@ +import { render } from "@react-email/render"; +import type React from "react"; +import { Resend } from "resend"; + +const resend = new Resend(process.env.RESEND_API_KEY); + +const FROM_EMAIL = process.env.EMAIL_FROM || "Sprint "; + +export interface SendEmailOptions { + to: string; + subject: string; + template: React.ReactElement; + from?: string; +} + +export async function sendEmail({ to, subject, template, from }: SendEmailOptions) { + const html = await render(template); + + const { data, error } = await resend.emails.send({ + from: from || FROM_EMAIL, + to, + subject, + html, + }); + + if (error) { + console.error("Failed to send email:", error); + throw new Error(`Email send failed: ${error.message}`); + } + + return data; +} + +export async function sendEmailWithRetry( + options: SendEmailOptions, + maxRetries = 3, +): Promise> { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await sendEmail(options); + } catch (error) { + lastError = error as Error; + console.warn(`Email send attempt ${attempt} failed:`, error); + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1))); + } + } + } + + throw lastError || new Error("Email send failed after all retries"); +} diff --git a/packages/backend/src/routes/auth/login.ts b/packages/backend/src/routes/auth/login.ts index 4bda879..84d5271 100644 --- a/packages/backend/src/routes/auth/login.ts +++ b/packages/backend/src/routes/auth/login.ts @@ -39,6 +39,7 @@ export default async function login(req: BunRequest) { username: user.username, avatarURL: user.avatarURL, iconPreference: user.iconPreference, + emailVerified: user.emailVerified, }, csrfToken: session.csrfToken, }), diff --git a/packages/backend/src/routes/auth/me.ts b/packages/backend/src/routes/auth/me.ts index b015e49..3007479 100644 --- a/packages/backend/src/routes/auth/me.ts +++ b/packages/backend/src/routes/auth/me.ts @@ -13,5 +13,6 @@ export default async function me(req: AuthedRequest) { return Response.json({ user: safeUser as Omit, csrfToken: req.csrfToken, + emailVerified: user.emailVerified, }); } diff --git a/packages/backend/src/routes/auth/register.ts b/packages/backend/src/routes/auth/register.ts index 37d8b3b..3bcaa3e 100644 --- a/packages/backend/src/routes/auth/register.ts +++ b/packages/backend/src/routes/auth/register.ts @@ -1,8 +1,10 @@ import { RegisterRequestSchema } from "@sprint/shared"; import type { BunRequest } from "bun"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; -import { createSession, createUser, getUserByUsername } from "../../db/queries"; +import { createSession, createUser, createVerificationCode, getUserByUsername } from "../../db/queries"; import { getUserByEmail } from "../../db/queries/users"; +import { VerificationCode } from "../../emails"; +import { sendEmailWithRetry } from "../../lib/email/service"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function register(req: BunRequest) { @@ -36,6 +38,19 @@ export default async function register(req: BunRequest) { return errorResponse("failed to create session", "SESSION_ERROR", 500); } + const verification = await createVerificationCode(user.id); + + try { + await sendEmailWithRetry({ + to: user.email, + subject: "Verify your Sprint account", + template: VerificationCode({ code: verification.code }), + }); + } catch (error) { + console.error("Failed to send verification email:", error); + // don't fail registration if email fails - user can resend + } + const token = generateToken(session.id, user.id); return new Response( @@ -46,6 +61,7 @@ export default async function register(req: BunRequest) { username: user.username, avatarURL: user.avatarURL, iconPreference: user.iconPreference, + emailVerified: user.emailVerified, }, csrfToken: session.csrfToken, }), diff --git a/packages/backend/src/routes/auth/resend-verification.ts b/packages/backend/src/routes/auth/resend-verification.ts new file mode 100644 index 0000000..5f69fdf --- /dev/null +++ b/packages/backend/src/routes/auth/resend-verification.ts @@ -0,0 +1,69 @@ +import type { BunRequest } from "bun"; +import type { AuthedRequest } from "../../auth/middleware"; +import { createVerificationCode } from "../../db/queries"; +import { getUserById } from "../../db/queries/users"; +import { VerificationCode } from "../../emails"; +import { sendEmailWithRetry } from "../../lib/email/service"; +import { errorResponse } from "../../validation"; + +const resendAttempts = new Map(); + +const MAX_RESENDS_PER_HOUR = 3; +const HOUR_IN_MS = 60 * 60 * 1000; + +function canResend(userId: number): boolean { + const now = Date.now(); + const attempts = resendAttempts.get(userId) || []; + + const recentAttempts = attempts.filter((time) => now - time < HOUR_IN_MS); + + if (recentAttempts.length >= MAX_RESENDS_PER_HOUR) { + return false; + } + + recentAttempts.push(now); + resendAttempts.set(userId, recentAttempts); + return true; +} + +export default async function resendVerification(req: BunRequest | AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + const authedReq = req as AuthedRequest; + if (!authedReq.userId) { + return errorResponse("unauthorized", "UNAUTHORIZED", 401); + } + + if (!canResend(authedReq.userId)) { + return errorResponse("too many resend attempts. please try again later", "RATE_LIMITED", 429); + } + + const user = await getUserById(authedReq.userId); + if (!user) { + return errorResponse("user not found", "USER_NOT_FOUND", 404); + } + + if (user.emailVerified) { + return errorResponse("email already verified", "ALREADY_VERIFIED", 400); + } + + const verification = await createVerificationCode(user.id); + + try { + await sendEmailWithRetry({ + to: user.email, + subject: "Verify your Sprint account", + template: VerificationCode({ code: verification.code }), + }); + } catch (error) { + console.error("Failed to send verification email:", error); + return errorResponse("failed to send verification email", "EMAIL_SEND_FAILED", 500); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/backend/src/routes/auth/verify-email.ts b/packages/backend/src/routes/auth/verify-email.ts new file mode 100644 index 0000000..1215d0d --- /dev/null +++ b/packages/backend/src/routes/auth/verify-email.ts @@ -0,0 +1,32 @@ +import { VerifyEmailRequestSchema } from "@sprint/shared"; +import type { BunRequest } from "bun"; +import type { AuthedRequest } from "../../auth/middleware"; +import { verifyCode } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; + +export default async function verifyEmail(req: BunRequest | AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + const authedReq = req as AuthedRequest; + if (!authedReq.userId) { + return errorResponse("unauthorized", "UNAUTHORIZED", 401); + } + + const parsed = await parseJsonBody(req, VerifyEmailRequestSchema); + if ("error" in parsed) return parsed.error; + + const { code } = parsed.data; + + const result = await verifyCode(authedReq.userId, code); + + if (!result.success) { + return errorResponse(result.error || "verification failed", "VERIFICATION_FAILED", 400); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 8ed144b..1d34d50 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -2,6 +2,8 @@ import authLogin from "./auth/login"; import authLogout from "./auth/logout"; import authMe from "./auth/me"; import authRegister from "./auth/register"; +import authResendVerification from "./auth/resend-verification"; +import authVerifyEmail from "./auth/verify-email"; import issueById from "./issue/by-id"; import issueCreate from "./issue/create"; import issueDelete from "./issue/delete"; @@ -57,6 +59,8 @@ export const routes = { authLogin, authLogout, authMe, + authVerifyEmail, + authResendVerification, userByUsername, userUpdate, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 71458e7..9c2b92e 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "@iconify/react": "^6.0.2", - "@ts-rest/core": "^3.52.1", "@nsmr/pixelart-react": "^2.0.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -26,33 +25,35 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sprint/shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.2", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-opener": "^2.5.3", + "@ts-rest/core": "^3.52.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "input-otp": "^1.4.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", - "react": "^19.1.0", + "react": "19.2.4", "react-colorful": "^5.6.1", "react-day-picker": "^9.13.0", - "react-dom": "^19.1.0", - "react-resizable-panels": "^4.0.15", - "react-router-dom": "^7.10.1", + "react-dom": "19.2.4", + "react-resizable-panels": "^4.5.3", + "react-router-dom": "^7.13.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18" }, "devDependencies": { - "@tauri-apps/cli": "^2", - "@types/node": "^25.0.1", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", + "@tauri-apps/cli": "^2.9.6", + "@types/node": "^25.1.0", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", "tw-animate-css": "^1.4.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.1" } } diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index 68187a8..70bf9cd 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -2,7 +2,6 @@ import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; import Avatar from "@/components/avatar"; import { useSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; @@ -26,9 +25,7 @@ export default function LogInForm({ showWarning: boolean; setShowWarning: (value: boolean) => void; }) { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { setUser } = useSession(); + const { setUser, setEmailVerified } = useSession(); const [loginDetailsOpen, setLoginDetailsOpen] = useState(false); @@ -59,8 +56,7 @@ export default function LogInForm({ const data = await res.json(); setCsrfToken(data.csrfToken); setUser(data.user); - const next = searchParams.get("next") || "/issues"; - navigate(next, { replace: true }); + setEmailVerified(data.user.emailVerified); } // unauthorized else if (res.status === 401) { @@ -98,8 +94,7 @@ export default function LogInForm({ const data = await res.json(); setCsrfToken(data.csrfToken); setUser(data.user); - const next = searchParams.get("next") || "/issues"; - navigate(next, { replace: true }); + setEmailVerified(data.user.emailVerified); } // bad request (probably a bad user input) else if (res.status === 400) { diff --git a/packages/frontend/src/components/login-modal.tsx b/packages/frontend/src/components/login-modal.tsx index 89b5177..dd482ef 100644 --- a/packages/frontend/src/components/login-modal.tsx +++ b/packages/frontend/src/components/login-modal.tsx @@ -15,21 +15,21 @@ interface LoginModalProps { export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { user, isLoading } = useSession(); + const { user, isLoading, emailVerified } = useSession(); const [hasRedirected, setHasRedirected] = useState(false); const [showWarning, setShowWarning] = useState(() => { return localStorage.getItem("hide-under-construction") !== "true"; }); useEffect(() => { - if (open && !isLoading && user && !hasRedirected) { + if (open && !isLoading && user && emailVerified && !hasRedirected) { setHasRedirected(true); const next = searchParams.get("next") || "/issues"; navigate(next, { replace: true }); onSuccess?.(); onOpenChange(false); } - }, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]); + }, [open, user, isLoading, emailVerified, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]); useEffect(() => { if (!open) { diff --git a/packages/frontend/src/components/session-provider.tsx b/packages/frontend/src/components/session-provider.tsx index 4eef767..b3d51d2 100644 --- a/packages/frontend/src/components/session-provider.tsx +++ b/packages/frontend/src/components/session-provider.tsx @@ -3,12 +3,16 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f import Loading from "@/components/loading"; import { LoginModal } from "@/components/login-modal"; +import { VerificationModal } from "@/components/verification-modal"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; interface SessionContextValue { user: UserResponse | null; setUser: (user: UserResponse) => void; isLoading: boolean; + emailVerified: boolean; + setEmailVerified: (verified: boolean) => void; + refreshUser: () => Promise; } const SessionContext = createContext(null); @@ -39,6 +43,7 @@ export function useAuthenticatedSession(): { user: UserResponse; setUser: (user: export function SessionProvider({ children }: { children: React.ReactNode }) { const [user, setUserState] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [emailVerified, setEmailVerified] = useState(true); const fetched = useRef(false); const setUser = useCallback((user: UserResponse) => { @@ -46,6 +51,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { localStorage.setItem("user", JSON.stringify(user)); }, []); + const refreshUser = useCallback(async () => { + const res = await fetch(`${getServerURL()}/auth/me`, { + credentials: "include", + }); + if (!res.ok) { + throw new Error(`auth check failed: ${res.status}`); + } + const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean }; + setUser(data.user); + setCsrfToken(data.csrfToken); + setEmailVerified(data.emailVerified); + }, [setUser]); + useEffect(() => { if (fetched.current) return; fetched.current = true; @@ -57,9 +75,10 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { if (!res.ok) { throw new Error(`auth check failed: ${res.status}`); } - const data = (await res.json()) as { user: UserResponse; csrfToken: string }; + const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean }; setUser(data.user); setCsrfToken(data.csrfToken); + setEmailVerified(data.emailVerified); }) .catch(() => { setUserState(null); @@ -70,11 +89,17 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }); }, [setUser]); - return {children}; + return ( + + {children} + + ); } export function RequireAuth({ children }: { children: React.ReactNode }) { - const { user, isLoading } = useSession(); + const { user, isLoading, emailVerified } = useSession(); const [loginModalOpen, setLoginModalOpen] = useState(false); useEffect(() => { @@ -93,5 +118,9 @@ export function RequireAuth({ children }: { children: React.ReactNode }) { return ; } + if (user && !emailVerified) { + return {}} />; + } + return <>{children}; } diff --git a/packages/frontend/src/components/ui/input-otp.tsx b/packages/frontend/src/components/ui/input-otp.tsx new file mode 100644 index 0000000..48808a3 --- /dev/null +++ b/packages/frontend/src/components/ui/input-otp.tsx @@ -0,0 +1,69 @@ +/** biome-ignore-all lint/a11y/useFocusableInteractive: <> */ +/** biome-ignore-all lint/a11y/useAriaPropsForRole: <> */ +/** biome-ignore-all lint/a11y/useSemanticElements: <> */ +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/packages/frontend/src/components/upload-avatar.tsx b/packages/frontend/src/components/upload-avatar.tsx index 8d4a535..754a6fc 100644 --- a/packages/frontend/src/components/upload-avatar.tsx +++ b/packages/frontend/src/components/upload-avatar.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; import { toast } from "sonner"; import Avatar from "@/components/avatar"; -import { useAuthenticatedSession } from "@/components/session-provider"; +import { useSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import Icon from "@/components/ui/icon"; import { Label } from "@/components/ui/label"; @@ -56,7 +56,7 @@ export function UploadAvatar({ skipOrgCheck?: boolean; className?: string; }) { - const { user } = useAuthenticatedSession(); + const { user } = useSession(); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); @@ -68,7 +68,7 @@ export function UploadAvatar({ if (!file) return; // check for animated GIF for free users - if (user.plan !== "pro" && file.type === "image/gif") { + if (user?.plan !== "pro" && file.type === "image/gif") { const isAnimated = await isAnimatedGIF(file); if (isAnimated) { setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars."); diff --git a/packages/frontend/src/components/verification-modal.tsx b/packages/frontend/src/components/verification-modal.tsx new file mode 100644 index 0000000..6000c07 --- /dev/null +++ b/packages/frontend/src/components/verification-modal.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { useSession } from "@/components/session-provider"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; +import { useResendVerification, useVerifyEmail } from "@/lib/query/hooks"; + +interface VerificationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function VerificationModal({ open, onOpenChange }: VerificationModalProps) { + const { refreshUser, setEmailVerified } = useSession(); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [resendSuccess, setResendSuccess] = useState(false); + + const verifyMutation = useVerifyEmail(); + const resendMutation = useResendVerification(); + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setResendSuccess(false); + + try { + await verifyMutation.mutateAsync({ code: code.trim() }); + setEmailVerified(true); + onOpenChange(false); + await refreshUser(); + } catch (err) { + setError(err instanceof Error ? err.message : "Verification failed"); + } + }; + + const handleResend = async () => { + setError(null); + setResendSuccess(false); + + try { + await resendMutation.mutateAsync(); + setResendSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to resend code"); + } + }; + + return ( + {}}> + + + Verify your email + + We've sent a 6-digit verification code to your email. Enter it below to complete your + registration. + + + +
+
+
+ + + + + + + + + + +
+ {error &&

{error}

} + {resendSuccess &&

Verification code sent!

} +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index b998c4e..7c0564f 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -7,3 +7,4 @@ export * from "@/lib/query/hooks/sprints"; export * from "@/lib/query/hooks/subscriptions"; export * from "@/lib/query/hooks/timers"; export * from "@/lib/query/hooks/users"; +export * from "@/lib/query/hooks/verification"; diff --git a/packages/frontend/src/lib/query/hooks/verification.ts b/packages/frontend/src/lib/query/hooks/verification.ts new file mode 100644 index 0000000..c0ebdfe --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/verification.ts @@ -0,0 +1,22 @@ +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/server"; + +export function useVerifyEmail() { + return useMutation({ + mutationKey: ["verification", "verify"], + mutationFn: async ({ code }) => { + const { error } = await apiClient.authVerifyEmail({ body: { code } }); + if (error) throw new Error(error); + }, + }); +} + +export function useResendVerification() { + return useMutation({ + mutationKey: ["verification", "resend"], + mutationFn: async () => { + const { error } = await apiClient.authResendVerification({ body: {} }); + if (error) throw new Error(error); + }, + }); +} diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index e02ecf4..54add57 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -60,12 +60,21 @@ export const AuthResponseSchema = z.object({ username: z.string(), avatarURL: z.string().nullable(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]), + emailVerified: z.boolean(), }), csrfToken: z.string(), }); export type AuthResponse = z.infer; +// email verification schemas + +export const VerifyEmailRequestSchema = z.object({ + code: z.string().length(6, "Verification code must be 6 digits"), +}); + +export type VerifyEmailRequest = z.infer; + // issue schemas export const IssueCreateRequestSchema = z.object({ diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index ba2261a..2580883 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -649,6 +649,30 @@ export const apiContract = c.router({ 500: ApiErrorSchema, }, }, + + authVerifyEmail: { + method: "POST", + path: "/auth/verify-email", + body: z.object({ code: z.string() }), + responses: { + 200: SuccessResponseSchema, + 400: ApiErrorSchema, + 401: ApiErrorSchema, + 404: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + authResendVerification: { + method: "POST", + path: "/auth/resend-verification", + body: emptyBodySchema, + responses: { + 200: SuccessResponseSchema, + 400: ApiErrorSchema, + 401: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5cfa1c3..ef65a17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -63,6 +63,7 @@ export type { UserByUsernameQuery, UserResponse, UserUpdateRequest, + VerifyEmailRequest, } from "./api-schemas"; // API schemas export { @@ -133,6 +134,7 @@ export { UserByUsernameQuerySchema, UserResponseSchema, UserUpdateRequestSchema, + VerifyEmailRequestSchema, } from "./api-schemas"; export { ISSUE_COMMENT_MAX_LENGTH, @@ -153,6 +155,10 @@ export { export type { ApiContract } from "./contract"; export { apiContract } from "./contract"; export type { + EmailJobInsert, + EmailJobRecord, + EmailVerificationInsert, + EmailVerificationRecord, IconStyle, IssueAssigneeInsert, IssueAssigneeRecord, @@ -191,6 +197,12 @@ export { DEFAULT_SPRINT_COLOUR, DEFAULT_STATUS_COLOUR, DEFAULT_STATUS_COLOURS, + EmailJob, + EmailJobInsertSchema, + EmailJobSelectSchema, + EmailVerification, + EmailVerificationInsertSchema, + EmailVerificationSelectSchema, Issue, IssueAssignee, IssueAssigneeInsertSchema, diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index b2166c5..c286cec 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -1,4 +1,4 @@ -import { boolean, integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { boolean, integer, json, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import type { z } from "zod"; import { @@ -62,6 +62,8 @@ export const User = pgTable("User", { avatarURL: varchar({ length: 512 }), iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type(), plan: varchar({ length: 32 }).notNull().default("free"), + emailVerified: boolean().notNull().default(false), + emailVerifiedAt: timestamp({ withTimezone: false }), createdAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); @@ -195,7 +197,6 @@ export const IssueComment = pgTable("IssueComment", { updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Zod schemas export const UserSelectSchema = createSelectSchema(User); export const UserInsertSchema = createInsertSchema(User); @@ -226,7 +227,6 @@ export const SessionInsertSchema = createInsertSchema(Session); export const TimedSessionSelectSchema = createSelectSchema(TimedSession); export const TimedSessionInsertSchema = createInsertSchema(TimedSession); -// Types export type UserRecord = z.infer; export type UserInsert = z.infer; @@ -260,8 +260,6 @@ export type SessionInsert = z.infer; export type TimedSessionRecord = z.infer; export type TimedSessionInsert = z.infer; -// Responses - export type IssueResponse = { Issue: IssueRecord; Creator: UserRecord; @@ -299,7 +297,6 @@ export type TimerState = { endedAt: string | null; } | null; -// Subscription table - tracks user subscriptions export const Subscription = pgTable("Subscription", { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer() @@ -319,7 +316,6 @@ export const Subscription = pgTable("Subscription", { updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Payment history table export const Payment = pgTable("Payment", { id: integer().primaryKey().generatedAlwaysAsIdentity(), subscriptionId: integer() @@ -332,16 +328,53 @@ export const Payment = pgTable("Payment", { createdAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Zod schemas for Subscription and Payment export const SubscriptionSelectSchema = createSelectSchema(Subscription); export const SubscriptionInsertSchema = createInsertSchema(Subscription); export const PaymentSelectSchema = createSelectSchema(Payment); export const PaymentInsertSchema = createInsertSchema(Payment); -// Types for Subscription and Payment export type SubscriptionRecord = z.infer; export type SubscriptionInsert = z.infer; export type PaymentRecord = z.infer; export type PaymentInsert = z.infer; + +export const EmailVerification = pgTable("EmailVerification", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer() + .notNull() + .references(() => User.id, { onDelete: "cascade" }), + code: varchar({ length: 6 }).notNull(), + attempts: integer().notNull().default(0), + maxAttempts: integer().notNull().default(5), + expiresAt: timestamp({ withTimezone: false }).notNull(), + verifiedAt: timestamp({ withTimezone: false }), + createdAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +export const EmailVerificationSelectSchema = createSelectSchema(EmailVerification); +export const EmailVerificationInsertSchema = createInsertSchema(EmailVerification); + +export type EmailVerificationRecord = z.infer; +export type EmailVerificationInsert = z.infer; + +export const EmailJob = pgTable("EmailJob", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer() + .notNull() + .references(() => User.id, { onDelete: "cascade" }), + type: varchar({ length: 64 }).notNull(), + scheduledFor: timestamp({ withTimezone: false }).notNull(), + sentAt: timestamp({ withTimezone: false }), + failedAt: timestamp({ withTimezone: false }), + errorMessage: text(), + metadata: json("metadata").$type>(), + createdAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +export const EmailJobSelectSchema = createSelectSchema(EmailJob); +export const EmailJobInsertSchema = createInsertSchema(EmailJob); + +export type EmailJobRecord = z.infer; +export type EmailJobInsert = z.infer;