mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
206
bun.lock
206
bun.lock
@@ -13,13 +13,19 @@
|
||||
"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",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,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",
|
||||
},
|
||||
@@ -41,6 +49,7 @@
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@nsmr/pixelart-react": "^2.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -60,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",
|
||||
@@ -278,6 +288,8 @@
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
@@ -346,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=="],
|
||||
@@ -398,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=="],
|
||||
@@ -434,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=="],
|
||||
|
||||
@@ -482,7 +540,7 @@
|
||||
|
||||
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
@@ -490,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=="],
|
||||
@@ -516,7 +576,11 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
|
||||
|
||||
@@ -546,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=="],
|
||||
@@ -558,36 +632,72 @@
|
||||
|
||||
"drizzle-zod": ["drizzle-zod@0.5.1", "", { "peerDependencies": { "drizzle-orm": ">=0.23.13", "zod": "*" } }, "sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -604,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=="],
|
||||
@@ -628,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=="],
|
||||
@@ -648,6 +768,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -660,6 +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=="],
|
||||
@@ -690,13 +824,21 @@
|
||||
|
||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
"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.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=="],
|
||||
|
||||
@@ -704,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=="],
|
||||
@@ -722,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=="],
|
||||
@@ -732,6 +880,14 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -742,12 +898,18 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"stripe": ["stripe@20.2.0", "", { "dependencies": { "qs": "^6.14.1" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -756,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=="],
|
||||
@@ -766,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=="],
|
||||
@@ -774,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=="],
|
||||
@@ -796,6 +964,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -824,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=="],
|
||||
|
||||
@@ -6,8 +6,14 @@ CORS_ORIGIN=http://localhost:1420
|
||||
# openssl rand -base64 32
|
||||
JWT_SECRET=jwt_secret_here
|
||||
|
||||
S3_PUBLIC_URL=https://issuebucket.ob248.com
|
||||
S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/issue
|
||||
S3_PUBLIC_URL=https://images.sprintpm.org
|
||||
S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint
|
||||
S3_ACCESS_KEY_ID=your_access_key_id
|
||||
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
|
||||
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
EMAIL_FROM=Sprint <support@sprintpm.org>
|
||||
30
packages/backend/drizzle/0026_stale_shocker.sql
Normal file
30
packages/backend/drizzle/0026_stale_shocker.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE "Payment" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Payment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"subscriptionId" integer NOT NULL,
|
||||
"stripePaymentIntentId" varchar(255),
|
||||
"amount" integer NOT NULL,
|
||||
"currency" varchar(3) DEFAULT 'gbp' NOT NULL,
|
||||
"status" varchar(32) NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Subscription_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"userId" integer NOT NULL,
|
||||
"stripeCustomerId" varchar(255),
|
||||
"stripeSubscriptionId" varchar(255),
|
||||
"stripeSubscriptionItemId" varchar(255),
|
||||
"stripePriceId" varchar(255),
|
||||
"status" varchar(32) DEFAULT 'incomplete' NOT NULL,
|
||||
"currentPeriodStart" timestamp,
|
||||
"currentPeriodEnd" timestamp,
|
||||
"cancelAtPeriodEnd" boolean DEFAULT false NOT NULL,
|
||||
"trialEnd" timestamp,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now(),
|
||||
"updatedAt" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "User" ADD COLUMN "plan" varchar(32) DEFAULT 'free' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_subscriptionId_Subscription_id_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."Subscription"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
|
||||
4
packages/backend/drizzle/0027_volatile_otto_octavius.sql
Normal file
4
packages/backend/drizzle/0027_volatile_otto_octavius.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "User" ADD COLUMN "email" varchar(255);--> statement-breakpoint
|
||||
UPDATE "User" SET "email" = 'user_' || id || '@placeholder.local' WHERE "email" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_email_unique" UNIQUE("email");
|
||||
28
packages/backend/drizzle/0028_quick_supernaut.sql
Normal file
28
packages/backend/drizzle/0028_quick_supernaut.sql
Normal file
@@ -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;
|
||||
1146
packages/backend/drizzle/meta/0026_snapshot.json
Normal file
1146
packages/backend/drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
packages/backend/drizzle/meta/0027_snapshot.json
Normal file
1159
packages/backend/drizzle/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1354
packages/backend/drizzle/meta/0028_snapshot.json
Normal file
1354
packages/backend/drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,27 @@
|
||||
"when": 1769549697892,
|
||||
"tag": "0025_sharp_quicksilver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1769615487574,
|
||||
"tag": "0026_stale_shocker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1769635016079,
|
||||
"tag": "0027_volatile_otto_octavius",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1769643481882,
|
||||
"tag": "0028_quick_supernaut",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,13 +29,19 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,15 +97,15 @@ const issueComments = [
|
||||
|
||||
const passwordHash = await hashPassword("a");
|
||||
const users = [
|
||||
{ name: "user 1", username: "u1", passwordHash, avatarURL: null },
|
||||
{ name: "user 2", username: "u2", passwordHash, avatarURL: null },
|
||||
{ name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null },
|
||||
// anything past here is just to have more users to assign issues to
|
||||
{ name: "user 3", username: "u3", passwordHash, avatarURL: null },
|
||||
{ name: "user 4", username: "u4", passwordHash, avatarURL: null },
|
||||
{ name: "user 5", username: "u5", passwordHash, avatarURL: null },
|
||||
{ name: "user 6", username: "u6", passwordHash, avatarURL: null },
|
||||
{ name: "user 7", username: "u7", passwordHash, avatarURL: null },
|
||||
{ name: "user 8", username: "u8", passwordHash, avatarURL: null },
|
||||
{ name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null },
|
||||
{ name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null },
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
|
||||
121
packages/backend/src/db/queries/email-verification.ts
Normal file
121
packages/backend/src/db/queries/email-verification.ts
Normal file
@@ -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<EmailVerificationRecord> {
|
||||
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<EmailVerificationRecord | undefined> {
|
||||
const [verification] = await db
|
||||
.select()
|
||||
.from(EmailVerification)
|
||||
.where(eq(EmailVerification.userId, userId));
|
||||
return verification;
|
||||
}
|
||||
|
||||
export async function incrementAttempts(id: number): Promise<void> {
|
||||
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<void> {
|
||||
await db.update(EmailVerification).set({ verifiedAt: new Date() }).where(eq(EmailVerification.id, id));
|
||||
}
|
||||
|
||||
export async function deleteVerification(id: number): Promise<void> {
|
||||
await db.delete(EmailVerification).where(eq(EmailVerification.id, id));
|
||||
}
|
||||
|
||||
export async function deleteUserVerifications(userId: number): Promise<void> {
|
||||
await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId));
|
||||
}
|
||||
|
||||
export async function cleanupExpiredVerifications(): Promise<number> {
|
||||
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 };
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
export * from "./email-verification";
|
||||
export * from "./issue-comments";
|
||||
export * from "./issues";
|
||||
export * from "./organisations";
|
||||
export * from "./projects";
|
||||
export * from "./sessions";
|
||||
export * from "./sprints";
|
||||
export * from "./subscriptions";
|
||||
export * from "./timed-sessions";
|
||||
export * from "./users";
|
||||
|
||||
// free tier limits
|
||||
export const FREE_TIER_LIMITS = {
|
||||
organisationsPerUser: 1,
|
||||
projectsPerOrganisation: 1,
|
||||
issuesPerOrganisation: 100,
|
||||
membersPerOrganisation: 5,
|
||||
sprintsPerProject: 5,
|
||||
} as const;
|
||||
|
||||
@@ -259,6 +259,25 @@ export async function getIssueAssigneeCount(issueId: number): Promise<number> {
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function getOrganisationIssueCount(organisationId: number): Promise<number> {
|
||||
const { Project } = await import("@sprint/shared");
|
||||
|
||||
const projects = await db
|
||||
.select({ id: Project.id })
|
||||
.from(Project)
|
||||
.where(eq(Project.organisationId, organisationId));
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
if (projectIds.length === 0) return 0;
|
||||
|
||||
const [result] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(Issue)
|
||||
.where(inArray(Issue.projectId, projectIds));
|
||||
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> {
|
||||
const [assignee] = await db
|
||||
.select({ id: IssueAssignee.id })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Organisation, OrganisationMember, User } from "@sprint/shared";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function createOrganisation(name: string, slug: string, description?: string) {
|
||||
@@ -144,3 +144,21 @@ export async function updateOrganisationMemberRole(organisationId: number, userI
|
||||
.returning();
|
||||
return member;
|
||||
}
|
||||
|
||||
export async function getUserOrganisationCount(userId: number): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(OrganisationMember)
|
||||
.where(eq(OrganisationMember.userId, userId));
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function getOrganisationOwner(organisationId: number) {
|
||||
const [owner] = await db
|
||||
.select({ userId: OrganisationMember.userId })
|
||||
.from(OrganisationMember)
|
||||
.where(
|
||||
and(eq(OrganisationMember.organisationId, organisationId), eq(OrganisationMember.role, "owner")),
|
||||
);
|
||||
return owner;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function createProject(key: string, name: string, creatorId: number, organisationId: number) {
|
||||
@@ -82,3 +82,11 @@ export async function getProjectsByOrganisationId(organisationId: number) {
|
||||
.leftJoin(Organisation, eq(Project.organisationId, Organisation.id));
|
||||
return projects;
|
||||
}
|
||||
|
||||
export async function getOrganisationProjectCount(organisationId: number): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(Project)
|
||||
.where(eq(Project.organisationId, organisationId));
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Issue, Sprint } from "@sprint/shared";
|
||||
import { and, desc, eq, gte, lte, ne } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, lte, ne, sql } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function createSprint(
|
||||
@@ -72,3 +72,11 @@ export async function deleteSprint(sprintId: number) {
|
||||
await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
|
||||
await db.delete(Sprint).where(eq(Sprint.id, sprintId));
|
||||
}
|
||||
|
||||
export async function getProjectSprintCount(projectId: number) {
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(Sprint)
|
||||
.where(eq(Sprint.projectId, projectId));
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
77
packages/backend/src/db/queries/subscriptions.ts
Normal file
77
packages/backend/src/db/queries/subscriptions.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Payment, Subscription } from "@sprint/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function createSubscription(data: {
|
||||
userId: number;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
stripeSubscriptionItemId: string;
|
||||
stripePriceId: string;
|
||||
status: string;
|
||||
quantity: number;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
trialEnd?: Date;
|
||||
}) {
|
||||
const [subscription] = await db
|
||||
.insert(Subscription)
|
||||
.values({
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
export async function getSubscriptionByUserId(userId: number) {
|
||||
const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
export async function getSubscriptionByStripeId(stripeSubscriptionId: string) {
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(Subscription)
|
||||
.where(eq(Subscription.stripeSubscriptionId, stripeSubscriptionId));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
id: number,
|
||||
updates: Partial<{
|
||||
status: string;
|
||||
stripePriceId: string;
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEnd: Date;
|
||||
quantity: number;
|
||||
}>,
|
||||
) {
|
||||
const [subscription] = await db
|
||||
.update(Subscription)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(Subscription.id, id))
|
||||
.returning();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
export async function createPayment(data: {
|
||||
subscriptionId: number;
|
||||
stripePaymentIntentId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
}) {
|
||||
const [payment] = await db.insert(Payment).values(data).returning();
|
||||
return payment;
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: number) {
|
||||
await db.delete(Subscription).where(eq(Subscription.id, id));
|
||||
}
|
||||
@@ -1,6 +1,45 @@
|
||||
import { Issue, Project, TimedSession } from "@sprint/shared";
|
||||
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
import { Issue, OrganisationMember, Project, TimedSession } from "@sprint/shared";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { db } from "../client"; // Import OrganisationMember and gte, inArray for the new query
|
||||
|
||||
export async function getOrganisationMemberTimedSessions(organisationId: number, fromDate?: Date) {
|
||||
// First get all member user IDs for the organisation
|
||||
const members = await db
|
||||
.select({ userId: OrganisationMember.userId })
|
||||
.from(OrganisationMember)
|
||||
.where(eq(OrganisationMember.organisationId, organisationId));
|
||||
|
||||
const userIds = members.map((m) => m.userId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build the where clause
|
||||
const conditions = [inArray(TimedSession.userId, userIds)];
|
||||
if (fromDate) {
|
||||
conditions.push(gte(TimedSession.createdAt, fromDate));
|
||||
}
|
||||
|
||||
const timedSessions = await db
|
||||
.select({
|
||||
id: TimedSession.id,
|
||||
userId: TimedSession.userId,
|
||||
issueId: TimedSession.issueId,
|
||||
timestamps: TimedSession.timestamps,
|
||||
endedAt: TimedSession.endedAt,
|
||||
createdAt: TimedSession.createdAt,
|
||||
issueNumber: Issue.number,
|
||||
projectKey: Project.key,
|
||||
})
|
||||
.from(TimedSession)
|
||||
.innerJoin(Issue, eq(TimedSession.issueId, Issue.id))
|
||||
.innerJoin(Project, eq(Issue.projectId, Project.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(TimedSession.createdAt));
|
||||
|
||||
return timedSessions;
|
||||
}
|
||||
|
||||
export async function createTimedSession(userId: number, issueId: number) {
|
||||
const [timedSession] = await db
|
||||
|
||||
@@ -5,10 +5,14 @@ import { db } from "../client";
|
||||
export async function createUser(
|
||||
name: string,
|
||||
username: string,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
avatarURL?: string | null,
|
||||
) {
|
||||
const [user] = await db.insert(User).values({ name, username, passwordHash, avatarURL }).returning();
|
||||
const [user] = await db
|
||||
.insert(User)
|
||||
.values({ name, username, email, passwordHash, avatarURL })
|
||||
.returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -22,6 +26,11 @@ export async function getUserByUsername(username: string) {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(User).where(eq(User.email, email));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateById(
|
||||
id: number,
|
||||
updates: {
|
||||
@@ -29,8 +38,14 @@ export async function updateById(
|
||||
passwordHash?: string;
|
||||
avatarURL?: string | null;
|
||||
iconPreference?: IconStyle;
|
||||
plan?: string;
|
||||
},
|
||||
): Promise<UserRecord | undefined> {
|
||||
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, updates: { plan?: string }) {
|
||||
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
1
packages/backend/src/emails/index.ts
Normal file
1
packages/backend/src/emails/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VerificationCode } from "./templates/VerificationCode";
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VerificationCode({ code }: { code: string }) {
|
||||
return <body>Your sprint verification code is: {code}</body>;
|
||||
}
|
||||
@@ -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))),
|
||||
@@ -68,6 +70,9 @@ const main = async () => {
|
||||
"/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
|
||||
"/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))),
|
||||
"/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)),
|
||||
"/organisation/member-time-tracking": withGlobalAuthed(
|
||||
withAuth(routes.organisationMemberTimeTracking),
|
||||
),
|
||||
"/organisation/remove-member": withGlobalAuthed(
|
||||
withAuth(withCSRF(routes.organisationRemoveMember)),
|
||||
),
|
||||
@@ -97,6 +102,17 @@ const main = async () => {
|
||||
"/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))),
|
||||
"/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))),
|
||||
"/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))),
|
||||
|
||||
// subscription routes - webhook has no auth
|
||||
"/subscription/create-checkout-session": withGlobalAuthed(
|
||||
withAuth(withCSRF(routes.subscriptionCreateCheckoutSession)),
|
||||
),
|
||||
"/subscription/create-portal-session": withGlobalAuthed(
|
||||
withAuth(withCSRF(routes.subscriptionCreatePortalSession)),
|
||||
),
|
||||
"/subscription/cancel": withGlobalAuthed(withAuth(withCSRF(routes.subscriptionCancel))),
|
||||
"/subscription/get": withGlobalAuthed(withAuth(routes.subscriptionGet)),
|
||||
"/subscription/webhook": withGlobal(routes.subscriptionWebhook),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
54
packages/backend/src/lib/email/service.ts
Normal file
54
packages/backend/src/lib/email/service.ts
Normal file
@@ -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 <noreply@sprint.app>";
|
||||
|
||||
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<ReturnType<typeof sendEmail>> {
|
||||
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");
|
||||
}
|
||||
49
packages/backend/src/lib/seats.ts
Normal file
49
packages/backend/src/lib/seats.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations";
|
||||
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
|
||||
import { getUserById } from "../db/queries/users";
|
||||
import { stripe } from "../stripe/client";
|
||||
|
||||
export async function updateSeatCount(userId: number) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
// only update if user has active pro subscription
|
||||
if (!user || user.plan !== "pro") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await getSubscriptionByUserId(userId);
|
||||
if (!subscription || subscription.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculate total members across all owned organisations
|
||||
const organisations = await getOrganisationsByUserId(userId);
|
||||
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
|
||||
|
||||
let totalMembers = 0;
|
||||
for (const org of ownedOrgs) {
|
||||
const members = await getOrganisationMembers(org.Organisation.id);
|
||||
totalMembers += members.length;
|
||||
}
|
||||
|
||||
const newQuantity = Math.max(1, totalMembers - 5);
|
||||
|
||||
// skip if quantity hasn't changed
|
||||
if (newQuantity === subscription.quantity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeSubscriptionItemId = subscription.stripeSubscriptionItemId;
|
||||
if (!stripeSubscriptionItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update stripe
|
||||
await stripe.subscriptionItems.update(stripeSubscriptionItemId, {
|
||||
quantity: newQuantity,
|
||||
proration_behavior: "always_invoice",
|
||||
});
|
||||
|
||||
// update local record
|
||||
await updateSubscription(subscription.id, { quantity: newQuantity });
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -13,5 +13,6 @@ export default async function me(req: AuthedRequest) {
|
||||
return Response.json({
|
||||
user: safeUser as Omit<UserRecord, "passwordHash">,
|
||||
csrfToken: req.csrfToken,
|
||||
emailVerified: user.emailVerified,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +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) {
|
||||
@@ -12,15 +15,20 @@ export default async function register(req: BunRequest) {
|
||||
const parsed = await parseJsonBody(req, RegisterRequestSchema);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
const { name, username, password, avatarURL } = parsed.data;
|
||||
const { name, username, email, password, avatarURL } = parsed.data;
|
||||
|
||||
const existing = await getUserByUsername(username);
|
||||
if (existing) {
|
||||
const existingUsername = await getUserByUsername(username);
|
||||
if (existingUsername) {
|
||||
return errorResponse("username already taken", "USERNAME_TAKEN", 400);
|
||||
}
|
||||
|
||||
const existingEmail = await getUserByEmail(email);
|
||||
if (existingEmail) {
|
||||
return errorResponse("email already registered", "EMAIL_TAKEN", 400);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await createUser(name, username, passwordHash, avatarURL);
|
||||
const user = await createUser(name, username, email, passwordHash, avatarURL);
|
||||
if (!user) {
|
||||
return errorResponse("failed to create user", "USER_CREATE_ERROR", 500);
|
||||
}
|
||||
@@ -30,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(
|
||||
@@ -40,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,
|
||||
}),
|
||||
|
||||
69
packages/backend/src/routes/auth/resend-verification.ts
Normal file
69
packages/backend/src/routes/auth/resend-verification.ts
Normal file
@@ -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<number, number[]>();
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
32
packages/backend/src/routes/auth/verify-email.ts
Normal file
32
packages/backend/src/routes/auth/verify-email.ts
Normal file
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -20,6 +22,7 @@ import organisationById from "./organisation/by-id";
|
||||
import organisationsByUser from "./organisation/by-user";
|
||||
import organisationCreate from "./organisation/create";
|
||||
import organisationDelete from "./organisation/delete";
|
||||
import organisationMemberTimeTracking from "./organisation/member-time-tracking";
|
||||
import organisationMembers from "./organisation/members";
|
||||
import organisationRemoveMember from "./organisation/remove-member";
|
||||
import organisationUpdate from "./organisation/update";
|
||||
@@ -37,6 +40,11 @@ import sprintCreate from "./sprint/create";
|
||||
import sprintDelete from "./sprint/delete";
|
||||
import sprintUpdate from "./sprint/update";
|
||||
import sprintsByProject from "./sprints/by-project";
|
||||
import subscriptionCancel from "./subscription/cancel";
|
||||
import subscriptionCreateCheckoutSession from "./subscription/create-checkout-session";
|
||||
import subscriptionCreatePortalSession from "./subscription/create-portal-session";
|
||||
import subscriptionGet from "./subscription/get";
|
||||
import subscriptionWebhook from "./subscription/webhook";
|
||||
import timerEnd from "./timer/end";
|
||||
import timerGet from "./timer/get";
|
||||
import timerGetInactive from "./timer/get-inactive";
|
||||
@@ -51,6 +59,8 @@ export const routes = {
|
||||
authLogin,
|
||||
authLogout,
|
||||
authMe,
|
||||
authVerifyEmail,
|
||||
authResendVerification,
|
||||
|
||||
userByUsername,
|
||||
userUpdate,
|
||||
@@ -77,6 +87,7 @@ export const routes = {
|
||||
organisationUpdate,
|
||||
organisationDelete,
|
||||
organisationAddMember,
|
||||
organisationMemberTimeTracking,
|
||||
organisationMembers,
|
||||
organisationRemoveMember,
|
||||
organisationUpdateMemberRole,
|
||||
@@ -104,4 +115,10 @@ export const routes = {
|
||||
timerGetInactive,
|
||||
timerEnd,
|
||||
timers,
|
||||
|
||||
subscriptionCreateCheckoutSession,
|
||||
subscriptionCreatePortalSession,
|
||||
subscriptionCancel,
|
||||
subscriptionGet,
|
||||
subscriptionWebhook,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { IssueCreateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
|
||||
import {
|
||||
createIssue,
|
||||
FREE_TIER_LIMITS,
|
||||
getOrganisationIssueCount,
|
||||
getOrganisationMemberRole,
|
||||
getProjectByID,
|
||||
getUserById,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function issueCreate(req: AuthedRequest) {
|
||||
@@ -26,6 +33,19 @@ export default async function issueCreate(req: AuthedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// check free tier limit
|
||||
const user = await getUserById(req.userId);
|
||||
if (user && user.plan !== "pro") {
|
||||
const issueCount = await getOrganisationIssueCount(project.organisationId);
|
||||
if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) {
|
||||
return errorResponse(
|
||||
`free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`,
|
||||
"FREE_TIER_ISSUE_LIMIT",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const issue = await createIssue(
|
||||
project.id,
|
||||
title,
|
||||
|
||||
@@ -2,10 +2,13 @@ import { OrgAddMemberRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import {
|
||||
createOrganisationMember,
|
||||
FREE_TIER_LIMITS,
|
||||
getOrganisationById,
|
||||
getOrganisationMemberRole,
|
||||
getOrganisationMembers,
|
||||
getUserById,
|
||||
} from "../../db/queries";
|
||||
import { updateSeatCount } from "../../lib/seats";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function organisationAddMember(req: AuthedRequest) {
|
||||
@@ -38,7 +41,25 @@ export default async function organisationAddMember(req: AuthedRequest) {
|
||||
return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403);
|
||||
}
|
||||
|
||||
// check free tier member limit
|
||||
const requester = await getUserById(req.userId);
|
||||
if (requester && requester.plan !== "pro") {
|
||||
const members = await getOrganisationMembers(organisationId);
|
||||
if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) {
|
||||
return errorResponse(
|
||||
`free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`,
|
||||
"FREE_TIER_MEMBER_LIMIT",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const member = await createOrganisationMember(organisationId, userId, role);
|
||||
|
||||
// update seat count if the requester is the owner
|
||||
if (requesterMember.role === "owner") {
|
||||
await updateSeatCount(req.userId);
|
||||
}
|
||||
|
||||
return Response.json(member);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { OrgCreateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries";
|
||||
import {
|
||||
createOrganisationWithOwner,
|
||||
FREE_TIER_LIMITS,
|
||||
getOrganisationBySlug,
|
||||
getUserById,
|
||||
getUserOrganisationCount,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function organisationCreate(req: AuthedRequest) {
|
||||
@@ -14,6 +20,19 @@ export default async function organisationCreate(req: AuthedRequest) {
|
||||
return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409);
|
||||
}
|
||||
|
||||
// check free tier limit
|
||||
const user = await getUserById(req.userId);
|
||||
if (user && user.plan !== "pro") {
|
||||
const orgCount = await getUserOrganisationCount(req.userId);
|
||||
if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) {
|
||||
return errorResponse(
|
||||
`free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`,
|
||||
"FREE_TIER_ORG_LIMIT",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);
|
||||
|
||||
return Response.json(organisation);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
|
||||
import { z } from "zod";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import {
|
||||
getOrganisationById,
|
||||
getOrganisationMemberRole,
|
||||
getOrganisationMemberTimedSessions,
|
||||
getOrganisationOwner,
|
||||
getUserById,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseQueryParams } from "../../validation";
|
||||
|
||||
const OrgMemberTimeTrackingQuerySchema = z.object({
|
||||
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
|
||||
fromDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
// GET /organisation/member-time-tracking?organisationId=123&fromDate=2024-01-01
|
||||
export default async function organisationMemberTimeTracking(req: AuthedRequest) {
|
||||
const url = new URL(req.url);
|
||||
const parsed = parseQueryParams(url, OrgMemberTimeTrackingQuerySchema);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
const { organisationId, fromDate } = parsed.data;
|
||||
|
||||
// check organisation exists
|
||||
const organisation = await getOrganisationById(organisationId);
|
||||
if (!organisation) {
|
||||
return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
const memberRole = await getOrganisationMemberRole(organisationId, req.userId);
|
||||
if (!memberRole) {
|
||||
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
|
||||
}
|
||||
|
||||
const role = memberRole.role;
|
||||
if (role !== "owner" && role !== "admin") {
|
||||
return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403);
|
||||
}
|
||||
|
||||
// check if organisation owner has pro subscription
|
||||
const owner = await getOrganisationOwner(organisationId);
|
||||
const ownerUser = owner ? await getUserById(owner.userId) : null;
|
||||
const isPro = ownerUser?.plan === "pro";
|
||||
|
||||
const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate);
|
||||
|
||||
const enriched = sessions.map((session) => {
|
||||
const timestamps = session.timestamps.map((t) => new Date(t));
|
||||
const actualWorkTimeMs = calculateWorkTimeMs(timestamps);
|
||||
const actualBreakTimeMs = calculateBreakTimeMs(timestamps);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
issueId: session.issueId,
|
||||
issueNumber: session.issueNumber,
|
||||
projectKey: session.projectKey,
|
||||
timestamps: isPro ? session.timestamps : [],
|
||||
endedAt: isPro ? session.endedAt : null,
|
||||
createdAt: isPro ? session.createdAt : null,
|
||||
workTimeMs: isPro ? actualWorkTimeMs : 0,
|
||||
breakTimeMs: isPro ? actualBreakTimeMs : 0,
|
||||
isRunning: isPro ? session.endedAt === null && isTimerRunning(timestamps) : false,
|
||||
};
|
||||
});
|
||||
|
||||
return Response.json(enriched);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
|
||||
import { updateSeatCount } from "../../lib/seats";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function organisationRemoveMember(req: AuthedRequest) {
|
||||
@@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) {
|
||||
|
||||
await removeOrganisationMember(organisationId, userId);
|
||||
|
||||
// update seat count if the requester is the owner
|
||||
if (requesterMember.role === "owner") {
|
||||
await updateSeatCount(req.userId);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { OrgUpdateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
|
||||
import {
|
||||
getOrganisationById,
|
||||
getOrganisationMemberRole,
|
||||
getSubscriptionByUserId,
|
||||
updateOrganisation,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function organisationUpdate(req: AuthedRequest) {
|
||||
@@ -22,6 +27,19 @@ export default async function organisationUpdate(req: AuthedRequest) {
|
||||
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
|
||||
}
|
||||
|
||||
// block free users from updating features
|
||||
if (features !== undefined) {
|
||||
const subscription = await getSubscriptionByUserId(req.userId);
|
||||
const isPro = subscription?.status === "active";
|
||||
if (!isPro) {
|
||||
return errorResponse(
|
||||
"Feature toggling is only available on Pro. Upgrade to customize features.",
|
||||
"FEATURE_TOGGLE_PRO_ONLY",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) {
|
||||
return errorResponse(
|
||||
"at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ProjectCreateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { createProject, getOrganisationMemberRole, getProjectByKey, getUserById } from "../../db/queries";
|
||||
import {
|
||||
createProject,
|
||||
FREE_TIER_LIMITS,
|
||||
getOrganisationMemberRole,
|
||||
getOrganisationProjectCount,
|
||||
getProjectByKey,
|
||||
getUserById,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function projectCreate(req: AuthedRequest) {
|
||||
@@ -22,7 +29,19 @@ export default async function projectCreate(req: AuthedRequest) {
|
||||
return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403);
|
||||
}
|
||||
|
||||
// check free tier limit
|
||||
const creator = await getUserById(req.userId);
|
||||
if (creator && creator.plan !== "pro") {
|
||||
const projectCount = await getOrganisationProjectCount(organisationId);
|
||||
if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) {
|
||||
return errorResponse(
|
||||
`free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`,
|
||||
"FREE_TIER_PROJECT_LIMIT",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!creator) {
|
||||
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { SprintCreateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import {
|
||||
createSprint,
|
||||
FREE_TIER_LIMITS,
|
||||
getOrganisationMemberRole,
|
||||
getProjectByID,
|
||||
getProjectSprintCount,
|
||||
getSubscriptionByUserId,
|
||||
hasOverlappingSprints,
|
||||
} from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
@@ -28,6 +31,20 @@ export default async function sprintCreate(req: AuthedRequest) {
|
||||
return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403);
|
||||
}
|
||||
|
||||
// check free tier sprint limit
|
||||
const subscription = await getSubscriptionByUserId(req.userId);
|
||||
const isPro = subscription?.status === "active";
|
||||
if (!isPro) {
|
||||
const sprintCount = await getProjectSprintCount(projectId);
|
||||
if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) {
|
||||
return errorResponse(
|
||||
`Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`,
|
||||
"SPRINT_LIMIT_REACHED",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
|
||||
68
packages/backend/src/routes/subscription/cancel.ts
Normal file
68
packages/backend/src/routes/subscription/cancel.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
|
||||
import { getSubscriptionByUserId, updateSubscription } from "../../db/queries/subscriptions";
|
||||
import { stripe } from "../../stripe/client";
|
||||
import { errorResponse } from "../../validation";
|
||||
|
||||
async function handler(req: AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId } = req;
|
||||
const subscription = await getSubscriptionByUserId(userId);
|
||||
if (!subscription?.stripeSubscriptionId) {
|
||||
return errorResponse("no active subscription found", "NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
const stripeCurrent = (await stripe.subscriptions.retrieve(
|
||||
subscription.stripeSubscriptionId,
|
||||
)) as unknown as {
|
||||
status: string;
|
||||
cancel_at_period_end: boolean | null;
|
||||
current_period_end: number | null;
|
||||
};
|
||||
|
||||
const currentPeriodEnd = stripeCurrent.current_period_end
|
||||
? new Date(stripeCurrent.current_period_end * 1000)
|
||||
: undefined;
|
||||
|
||||
if (stripeCurrent.status === "canceled" || stripeCurrent.cancel_at_period_end) {
|
||||
const updated = await updateSubscription(subscription.id, {
|
||||
status: stripeCurrent.status,
|
||||
cancelAtPeriodEnd: stripeCurrent.cancel_at_period_end ?? subscription.cancelAtPeriodEnd,
|
||||
...(currentPeriodEnd && { currentPeriodEnd }),
|
||||
});
|
||||
return new Response(JSON.stringify({ subscription: updated }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const stripeSubscription = (await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})) as unknown as {
|
||||
status: string;
|
||||
cancel_at_period_end: boolean | null;
|
||||
current_period_end: number | null;
|
||||
};
|
||||
|
||||
const updated = await updateSubscription(subscription.id, {
|
||||
status: stripeSubscription.status,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? true,
|
||||
currentPeriodEnd: stripeSubscription.current_period_end
|
||||
? new Date(stripeSubscription.current_period_end * 1000)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ subscription: updated }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("subscription cancel error:", error);
|
||||
return errorResponse("failed to cancel subscription", "CANCEL_ERROR", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCors(withAuth(withCSRF(handler)));
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
|
||||
import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations";
|
||||
import { getUserById } from "../../db/queries/users";
|
||||
import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client";
|
||||
import { errorResponse } from "../../validation";
|
||||
|
||||
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
|
||||
|
||||
async function handler(req: AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { billingPeriod } = body as { billingPeriod: "monthly" | "annual" | undefined };
|
||||
|
||||
if (!billingPeriod) {
|
||||
return errorResponse("missing required fields", "VALIDATION_ERROR", 400);
|
||||
}
|
||||
|
||||
const { userId } = req;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("user not found", "NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
// calculate seat quantity across all owned organisations
|
||||
const organisations = await getOrganisationsByUserId(userId);
|
||||
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
|
||||
|
||||
let totalMembers = 0;
|
||||
for (const org of ownedOrgs) {
|
||||
const members = await getOrganisationMembers(org.Organisation.id);
|
||||
totalMembers += members.length;
|
||||
}
|
||||
|
||||
const quantity = Math.max(1, totalMembers - 5);
|
||||
const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY;
|
||||
|
||||
// use the user's email from the database
|
||||
const customerEmail = user.email;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer_email: customerEmail,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: quantity,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
success_url: `${BASE_URL}/plans?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${BASE_URL}/plans?canceled=true`,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
userId: userId.toString(),
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
userId: userId.toString(),
|
||||
priceId: priceId,
|
||||
quantity: quantity.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: session.url }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("checkout session error:", error);
|
||||
return errorResponse("failed to create checkout session", "CHECKOUT_ERROR", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCors(withAuth(withCSRF(handler)));
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
|
||||
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
|
||||
import { stripe } from "../../stripe/client";
|
||||
import { errorResponse } from "../../validation";
|
||||
|
||||
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
|
||||
|
||||
async function handler(req: AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId } = req;
|
||||
const subscription = await getSubscriptionByUserId(userId);
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return errorResponse("no active subscription found", "NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
return_url: `${BASE_URL}/plans`,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: portalSession.url }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("portal session error:", error);
|
||||
return errorResponse("failed to create portal session", "PORTAL_ERROR", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCors(withAuth(withCSRF(handler)));
|
||||
24
packages/backend/src/routes/subscription/get.ts
Normal file
24
packages/backend/src/routes/subscription/get.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type AuthedRequest, withAuth, withCors } from "../../auth/middleware";
|
||||
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
|
||||
import { errorResponse } from "../../validation";
|
||||
|
||||
async function handler(req: AuthedRequest) {
|
||||
if (req.method !== "GET") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId } = req;
|
||||
const subscription = await getSubscriptionByUserId(userId);
|
||||
|
||||
return new Response(JSON.stringify({ subscription }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("fetch subscription error:", error);
|
||||
return errorResponse("failed to fetch subscription", "FETCH_ERROR", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCors(withAuth(handler));
|
||||
213
packages/backend/src/routes/subscription/webhook.ts
Normal file
213
packages/backend/src/routes/subscription/webhook.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { BunRequest } from "bun";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
createPayment,
|
||||
createSubscription,
|
||||
getSubscriptionByStripeId,
|
||||
updateSubscription,
|
||||
} from "../../db/queries/subscriptions";
|
||||
import { updateUser } from "../../db/queries/users";
|
||||
import { stripe } from "../../stripe/client";
|
||||
|
||||
const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET");
|
||||
|
||||
function toStripeDate(seconds: number | null | undefined, field: string) {
|
||||
if (seconds === null || seconds === undefined) return undefined;
|
||||
if (!Number.isFinite(seconds)) {
|
||||
console.warn(`invalid ${field} timestamp:`, seconds);
|
||||
return undefined;
|
||||
}
|
||||
return new Date(seconds * 1000);
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export default async function webhook(req: BunRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const payload = await req.text();
|
||||
const signature = req.headers.get("stripe-signature");
|
||||
|
||||
if (!signature) {
|
||||
return new Response("Missing signature", { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
// use async version for Bun compatibility
|
||||
event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
console.error("webhook signature verification failed:", err);
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (session.mode !== "subscription" || !session.subscription) {
|
||||
break;
|
||||
}
|
||||
|
||||
const userId = parseInt(session.metadata?.userId || "0", 10);
|
||||
if (!userId) {
|
||||
console.error("missing userId in session metadata");
|
||||
break;
|
||||
}
|
||||
|
||||
// fetch full subscription to get item id
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string,
|
||||
);
|
||||
if (!stripeSubscription) {
|
||||
console.error("failed to retrieve subscription:", session.subscription);
|
||||
break;
|
||||
}
|
||||
if (!stripeSubscription.items.data[0]) {
|
||||
console.error("subscription has no items:", stripeSubscription.id);
|
||||
break;
|
||||
}
|
||||
|
||||
// stripe types use snake_case for these fields
|
||||
const sub = stripeSubscription as unknown as {
|
||||
current_period_start: number | null;
|
||||
current_period_end: number | null;
|
||||
trial_end: number | null;
|
||||
};
|
||||
|
||||
await createSubscription({
|
||||
userId,
|
||||
stripeCustomerId: session.customer as string,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
stripeSubscriptionItemId: stripeSubscription.items.data[0].id,
|
||||
stripePriceId: session.metadata?.priceId || "",
|
||||
status: stripeSubscription.status,
|
||||
quantity: parseInt(session.metadata?.quantity || "1", 10),
|
||||
currentPeriodStart: toStripeDate(sub.current_period_start, "current_period_start"),
|
||||
currentPeriodEnd: toStripeDate(sub.current_period_end, "current_period_end"),
|
||||
trialEnd: toStripeDate(sub.trial_end, "trial_end"),
|
||||
});
|
||||
|
||||
await updateUser(userId, { plan: "pro" });
|
||||
|
||||
console.log(`subscription activated for user ${userId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription) {
|
||||
console.error("failed to retrieve subscription (customer.subscription.updated)");
|
||||
break;
|
||||
}
|
||||
if (!subscription.items.data[0]) {
|
||||
console.error("subscription has no items:", subscription.id);
|
||||
break;
|
||||
}
|
||||
|
||||
const localSub = await getSubscriptionByStripeId(subscription.id);
|
||||
if (!localSub) {
|
||||
console.error("subscription not found:", subscription.id);
|
||||
break;
|
||||
}
|
||||
// safely convert timestamps to dates
|
||||
// stripe types use snake_case for these fields
|
||||
const sub = subscription as unknown as {
|
||||
current_period_start: number | null;
|
||||
current_period_end: number | null;
|
||||
};
|
||||
const currentPeriodStart = toStripeDate(sub.current_period_start, "current_period_start");
|
||||
const currentPeriodEnd = toStripeDate(sub.current_period_end, "current_period_end");
|
||||
|
||||
await updateSubscription(localSub.id, {
|
||||
status: subscription.status,
|
||||
...(currentPeriodStart && { currentPeriodStart }),
|
||||
...(currentPeriodEnd && { currentPeriodEnd }),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
quantity: subscription.items.data[0].quantity || 1,
|
||||
});
|
||||
|
||||
console.log(`subscription updated: ${subscription.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const localSub = await getSubscriptionByStripeId(subscription.id);
|
||||
if (!localSub) break;
|
||||
|
||||
// delete subscription from database
|
||||
const { deleteSubscription } = await import("../../db/queries/subscriptions");
|
||||
await deleteSubscription(localSub.id);
|
||||
await updateUser(localSub.userId, { plan: "free" });
|
||||
|
||||
console.log(`subscription deleted: ${subscription.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "invoice.payment_succeeded": {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
// stripe types use snake_case for these fields
|
||||
const inv = invoice as unknown as {
|
||||
subscription: string | null;
|
||||
payment_intent: string | null;
|
||||
};
|
||||
|
||||
if (!inv.subscription) break;
|
||||
|
||||
const localSub = await getSubscriptionByStripeId(inv.subscription);
|
||||
if (!localSub) break;
|
||||
|
||||
await createPayment({
|
||||
subscriptionId: localSub.id,
|
||||
stripePaymentIntentId: inv.payment_intent || "",
|
||||
amount: invoice.amount_paid,
|
||||
currency: invoice.currency,
|
||||
status: "succeeded",
|
||||
});
|
||||
|
||||
console.log(`payment recorded for subscription ${inv.subscription}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "invoice.payment_failed": {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
// stripe types use snake_case for these fields
|
||||
const inv = invoice as unknown as {
|
||||
subscription: string | null;
|
||||
};
|
||||
|
||||
if (!inv.subscription) break;
|
||||
|
||||
const localSub = await getSubscriptionByStripeId(inv.subscription);
|
||||
if (!localSub) break;
|
||||
|
||||
await updateSubscription(localSub.id, { status: "past_due" });
|
||||
|
||||
console.log(`payment failed for subscription ${inv.subscription}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("webhook processing error:", error);
|
||||
return new Response("Webhook handler failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UserUpdateRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { hashPassword } from "../../auth/utils";
|
||||
import { getUserById } from "../../db/queries";
|
||||
import { getSubscriptionByUserId, getUserById } from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function update(req: AuthedRequest) {
|
||||
@@ -23,6 +23,19 @@ export default async function update(req: AuthedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// block free users from changing icon preference
|
||||
if (iconPreference !== undefined && iconPreference !== user.iconPreference) {
|
||||
const subscription = await getSubscriptionByUserId(req.userId);
|
||||
const isPro = subscription?.status === "active";
|
||||
if (!isPro) {
|
||||
return errorResponse(
|
||||
"icon style customization is only available on Pro. Upgrade to customize your icon style.",
|
||||
"ICON_STYLE_PRO_ONLY",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let passwordHash: string | undefined;
|
||||
if (password !== undefined) {
|
||||
passwordHash = await hashPassword(password);
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { BunRequest } from "bun";
|
||||
import sharp from "sharp";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { getSubscriptionByUserId } from "../../db/queries";
|
||||
import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3";
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
const TARGET_SIZE = 256;
|
||||
|
||||
export default async function uploadAvatar(req: BunRequest) {
|
||||
async function isAnimatedGIF(buffer: Buffer): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return metadata.pages !== undefined && metadata.pages > 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function uploadAvatar(req: AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("method not allowed", { status: 405 });
|
||||
}
|
||||
@@ -29,14 +39,31 @@ export default async function uploadAvatar(req: BunRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const inputBuffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// check if user is pro
|
||||
const subscription = await getSubscriptionByUserId(req.userId);
|
||||
const isPro = subscription?.status === "active";
|
||||
|
||||
// block animated avatars for free users
|
||||
if (!isPro && file.type === "image/gif") {
|
||||
const animated = await isAnimatedGIF(inputBuffer);
|
||||
if (animated) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isGIF = file.type === "image/gif";
|
||||
const outputExtension = isGIF ? "gif" : "png";
|
||||
const outputMimeType = isGIF ? "image/gif" : "image/png";
|
||||
|
||||
let resizedBuffer: Buffer;
|
||||
try {
|
||||
const inputBuffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (isGIF) {
|
||||
resizedBuffer = await sharp(inputBuffer, { animated: true })
|
||||
.resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" })
|
||||
|
||||
18
packages/backend/src/stripe/client.ts
Normal file
18
packages/backend/src/stripe/client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripeSecretKey = requireEnv("STRIPE_SECRET_KEY");
|
||||
|
||||
export const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2025-12-15.clover",
|
||||
});
|
||||
|
||||
export const STRIPE_PRICE_MONTHLY = requireEnv("STRIPE_PRICE_MONTHLY");
|
||||
export const STRIPE_PRICE_ANNUAL = requireEnv("STRIPE_PRICE_ANNUAL");
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
},
|
||||
"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",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -25,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IconStyle } from "@sprint/shared";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
@@ -15,6 +16,9 @@ import { useUpdateUser } from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// icon style is locked to pixel for free users
|
||||
const DEFAULT_ICON_STYLE: IconStyle = "pixel";
|
||||
|
||||
function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
const { user: currentUser, setUser } = useAuthenticatedSession();
|
||||
const updateUser = useUpdateUser();
|
||||
@@ -34,7 +38,12 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
setName(currentUser.name);
|
||||
setUsername(currentUser.username);
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel");
|
||||
// free users are locked to pixel icon style
|
||||
const effectiveIconStyle =
|
||||
currentUser.plan === "pro"
|
||||
? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE)
|
||||
: DEFAULT_ICON_STYLE;
|
||||
setIconPreference(effectiveIconStyle);
|
||||
|
||||
setPassword("");
|
||||
setError("");
|
||||
@@ -50,11 +59,13 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
// only send iconPreference for pro users
|
||||
const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined;
|
||||
const data = await updateUser.mutateAsync({
|
||||
name: name.trim(),
|
||||
password: password.trim() || undefined,
|
||||
avatarURL,
|
||||
iconPreference,
|
||||
iconPreference: effectiveIconPreference,
|
||||
});
|
||||
setError("");
|
||||
setUser(data);
|
||||
@@ -130,9 +141,22 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
<ThemeToggle withText />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Icon Style</Label>
|
||||
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<Label className={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}>
|
||||
Icon Style
|
||||
</Label>
|
||||
<Select
|
||||
value={iconPreference}
|
||||
onValueChange={(v) => setIconPreference(v as IconStyle)}
|
||||
disabled={currentUser.plan !== "pro"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-full", currentUser.plan !== "pro" && "cursor-not-allowed opacity-60")}
|
||||
title={
|
||||
currentUser.plan !== "pro"
|
||||
? "icon style customization is only available on Pro"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start">
|
||||
@@ -156,12 +180,33 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentUser.plan !== "pro" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Link to="/plans" className="text-personality hover:underline">
|
||||
Upgrade to Pro
|
||||
</Link>{" "}
|
||||
to customize icon style
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
{/* Show subscription management link */}
|
||||
<div className="pt-2">
|
||||
{currentUser.plan === "pro" ? (
|
||||
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
|
||||
<Link to="/plans">Manage subscription</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
|
||||
<Link to="/plans">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant={"outline"} type={"submit"} className="px-12">
|
||||
Save
|
||||
</Button>
|
||||
|
||||
93
packages/frontend/src/components/free-tier-limit.tsx
Normal file
93
packages/frontend/src/components/free-tier-limit.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FreeTierLimit({
|
||||
current,
|
||||
limit,
|
||||
itemName,
|
||||
isPro,
|
||||
className,
|
||||
showUpgrade = true,
|
||||
}: {
|
||||
current: number;
|
||||
limit: number;
|
||||
itemName: string;
|
||||
isPro: boolean;
|
||||
className?: string;
|
||||
showUpgrade?: boolean;
|
||||
}) {
|
||||
if (isPro) return null;
|
||||
|
||||
const percentage = Math.min((current / limit) * 100, 100);
|
||||
const isAtLimit = current >= limit;
|
||||
const isNearLimit = percentage >= 80 && !isAtLimit;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{current} / {limit} {itemName}
|
||||
{current !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{isAtLimit && <span className="text-destructive font-medium">Limit reached</span>}
|
||||
{isNearLimit && <span className="text-yellow-600 font-medium">Almost at limit</span>}
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300",
|
||||
isAtLimit ? "bg-destructive" : isNearLimit ? "bg-yellow-500" : "bg-personality",
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{isAtLimit && showUpgrade && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Icon icon="info" className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Upgrade to Pro for unlimited {itemName}s</span>
|
||||
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs text-personality">
|
||||
<Link to="/plans">Upgrade</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FreeTierLimitBadgeProps {
|
||||
current: number;
|
||||
limit: number;
|
||||
itemName: string;
|
||||
isPro: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FreeTierLimitBadge({ current, limit, itemName, isPro, className }: FreeTierLimitBadgeProps) {
|
||||
if (isPro) return null;
|
||||
|
||||
const isAtLimit = current >= limit;
|
||||
const percentage = (current / limit) * 100;
|
||||
const isNearLimit = percentage >= 80 && !isAtLimit;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-md border",
|
||||
isAtLimit
|
||||
? "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
: isNearLimit
|
||||
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-700"
|
||||
: "bg-muted border-border text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon icon={isAtLimit ? "alertTriangle" : isNearLimit ? "info" : "check"} className="size-3.5" />
|
||||
<span>
|
||||
{current}/{limit} {itemName}
|
||||
{current !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/sh
|
||||
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { SprintSelect } from "@/components/sprint-select";
|
||||
@@ -23,6 +24,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import {
|
||||
useCreateIssue,
|
||||
useIssues,
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
@@ -31,14 +33,21 @@ import {
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn, issueID } from "@/lib/utils";
|
||||
|
||||
const FREE_TIER_ISSUE_LIMIT = 100;
|
||||
|
||||
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const { data: issues = [] } = useIssues(selectedProject?.Project.id);
|
||||
const createIssue = useCreateIssue();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const issueCount = issues.length;
|
||||
const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT;
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
||||
@@ -138,7 +147,17 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!selectedProject}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedProject || isAtIssueLimit}
|
||||
title={
|
||||
isAtIssueLimit
|
||||
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||
: !selectedProject
|
||||
? "Select a project first"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Create Issue
|
||||
</Button>
|
||||
)}
|
||||
@@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
<DialogTitle>Create Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!isPro && selectedProject && (
|
||||
<div className="mb-2">
|
||||
<FreeTierLimit
|
||||
current={issueCount}
|
||||
limit={FREE_TIER_ISSUE_LIMIT}
|
||||
itemName="issue"
|
||||
isPro={isPro}
|
||||
showUpgrade={isAtIssueLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid">
|
||||
{(typeOptions.length > 0 || statusOptions.length > 0) && (
|
||||
@@ -270,10 +301,16 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
isAtIssueLimit ||
|
||||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
||||
submitAttempted) ||
|
||||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
|
||||
}
|
||||
title={
|
||||
isAtIssueLimit
|
||||
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||
|
||||
import { USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
|
||||
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);
|
||||
|
||||
@@ -36,6 +33,7 @@ export default function LogInForm({
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -58,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) {
|
||||
@@ -75,7 +72,7 @@ export default function LogInForm({
|
||||
};
|
||||
|
||||
const register = () => {
|
||||
if (name.trim() === "" || username.trim() === "" || password.trim() === "") {
|
||||
if (name.trim() === "" || username.trim() === "" || email.trim() === "" || password.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +82,7 @@ export default function LogInForm({
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
avatarURL,
|
||||
}),
|
||||
@@ -96,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) {
|
||||
@@ -129,6 +126,7 @@ export default function LogInForm({
|
||||
setError("");
|
||||
setSubmitAttempted(false);
|
||||
setAvatarUrl(null);
|
||||
setEmail("");
|
||||
requestAnimationFrame(() => focusFirstInput());
|
||||
};
|
||||
|
||||
@@ -249,6 +247,15 @@ export default function LogInForm({
|
||||
spellcheck={false}
|
||||
maxLength={USER_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_EMAIL_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import { OrganisationForm } from "@/components/organisation-form";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,6 +19,8 @@ import { useOrganisations } from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import OrgIcon from "./org-icon";
|
||||
|
||||
const FREE_TIER_ORG_LIMIT = 1;
|
||||
|
||||
export function OrganisationSelect({
|
||||
placeholder = "Select Organisation",
|
||||
contentClass,
|
||||
@@ -40,6 +44,11 @@ export function OrganisationSelect({
|
||||
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
||||
const { user } = useAuthenticatedSession();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const orgCount = organisationsData.length;
|
||||
const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
@@ -107,9 +116,31 @@ export function OrganisationSelect({
|
||||
{organisations.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
{!isPro && (
|
||||
<div className="px-2 py-2">
|
||||
<FreeTierLimit
|
||||
current={orgCount}
|
||||
limit={FREE_TIER_ORG_LIMIT}
|
||||
itemName="organisation"
|
||||
isPro={isPro}
|
||||
showUpgrade={isAtOrgLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OrganisationForm
|
||||
trigger={
|
||||
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={"w-full"}
|
||||
size={"sm"}
|
||||
disabled={isAtOrgLimit}
|
||||
title={
|
||||
isAtOrgLimit
|
||||
? "Free tier limited to 1 organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Create Organisation
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from "@sprint/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { AddMember } from "@/components/add-member";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import OrgIcon from "@/components/org-icon";
|
||||
import { OrganisationForm } from "@/components/organisation-form";
|
||||
import { OrganisationSelect } from "@/components/organisation-select";
|
||||
@@ -22,6 +24,7 @@ import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { SprintForm } from "@/components/sprint-form";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import ColourPicker from "@/components/ui/colour-picker";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
@@ -34,13 +37,16 @@ import {
|
||||
import Icon, { type IconName, iconNames } from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
useDeleteOrganisation,
|
||||
useDeleteProject,
|
||||
useDeleteSprint,
|
||||
useIssues,
|
||||
useOrganisationMembers,
|
||||
useOrganisationMemberTimeTracking,
|
||||
useOrganisations,
|
||||
useProjects,
|
||||
useRemoveOrganisationMember,
|
||||
@@ -52,9 +58,16 @@ import {
|
||||
} from "@/lib/query/hooks";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { apiClient } from "@/lib/server";
|
||||
import { capitalise, unCamelCase } from "@/lib/utils";
|
||||
import { capitalise, cn, formatDuration, unCamelCase } from "@/lib/utils";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
const FREE_TIER_LIMITS = {
|
||||
organisationsPerUser: 1,
|
||||
projectsPerOrganisation: 1,
|
||||
issuesPerOrganisation: 100,
|
||||
membersPerOrganisation: 5,
|
||||
} as const;
|
||||
|
||||
function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -63,6 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: sprints = [] } = useSprints(selectedProjectId);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
||||
const { data: issues = [] } = useIssues(selectedProjectId);
|
||||
const updateOrganisation = useUpdateOrganisation();
|
||||
const updateMemberRole = useUpdateOrganisationMemberRole();
|
||||
const removeMember = useRemoveOrganisationMember();
|
||||
@@ -72,6 +86,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const replaceIssueStatus = useReplaceIssueStatus();
|
||||
const replaceIssueType = useReplaceIssueType();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const orgCount = organisationsData.length;
|
||||
const projectCount = projectsData.length;
|
||||
const issueCount = issues.length;
|
||||
const memberCount = membersData.length;
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
@@ -104,6 +124,15 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
);
|
||||
const invalidateSprints = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) });
|
||||
// time tracking state - must be before membersWithTimeTracking useMemo
|
||||
const [fromDate, setFromDate] = useState<Date>(() => {
|
||||
// default to same day of previous month
|
||||
const now = new Date();
|
||||
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
return prevMonth;
|
||||
});
|
||||
const { data: timeTrackingData = [] } = useOrganisationMemberTimeTracking(selectedOrganisationId, fromDate);
|
||||
|
||||
const members = useMemo(() => {
|
||||
const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 };
|
||||
return [...membersData].sort((a, b) => {
|
||||
@@ -114,6 +143,118 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
});
|
||||
}, [membersData]);
|
||||
|
||||
const membersWithTimeTracking = useMemo(() => {
|
||||
const timePerUser = new Map<number, number>();
|
||||
for (const session of timeTrackingData) {
|
||||
const current = timePerUser.get(session.userId) ?? 0;
|
||||
timePerUser.set(session.userId, current + (session.workTimeMs ?? 0));
|
||||
}
|
||||
|
||||
const membersWithTime = members.map((member) => ({
|
||||
...member,
|
||||
totalTimeMs: timePerUser.get(member.User.id) ?? 0,
|
||||
}));
|
||||
|
||||
const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 };
|
||||
return membersWithTime.sort((a, b) => {
|
||||
if (b.totalTimeMs !== a.totalTimeMs) {
|
||||
return b.totalTimeMs - a.totalTimeMs;
|
||||
}
|
||||
const roleA = roleOrder[a.OrganisationMember.role] ?? 3;
|
||||
const roleB = roleOrder[b.OrganisationMember.role] ?? 3;
|
||||
if (roleA !== roleB) return roleA - roleB;
|
||||
return a.User.name.localeCompare(b.User.name);
|
||||
});
|
||||
}, [members, timeTrackingData]);
|
||||
|
||||
const downloadTimeTrackingData = (format: "csv" | "json") => {
|
||||
if (!selectedOrganisation) return;
|
||||
|
||||
const userData = new Map<
|
||||
number,
|
||||
{
|
||||
userId: number;
|
||||
name: string;
|
||||
username: string;
|
||||
totalTimeMs: number;
|
||||
sessions: typeof timeTrackingData;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const member of members) {
|
||||
userData.set(member.User.id, {
|
||||
userId: member.User.id,
|
||||
name: member.User.name,
|
||||
username: member.User.username,
|
||||
totalTimeMs: 0,
|
||||
sessions: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const session of timeTrackingData) {
|
||||
const user = userData.get(session.userId);
|
||||
if (user) {
|
||||
user.totalTimeMs += session.workTimeMs;
|
||||
user.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs);
|
||||
|
||||
// generate CSV or JSON
|
||||
if (format === "csv") {
|
||||
const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"];
|
||||
const rows = data.map((user) => [
|
||||
user.userId,
|
||||
user.name,
|
||||
user.username,
|
||||
user.totalTimeMs,
|
||||
formatDuration(user.totalTimeMs),
|
||||
]);
|
||||
const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
// download
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const json = JSON.stringify(
|
||||
{
|
||||
organisation: selectedOrganisation.Organisation.name,
|
||||
fromDate: fromDate.toISOString(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
members: data.map((user) => ({
|
||||
...user,
|
||||
totalTimeFormatted: formatDuration(user.totalTimeMs),
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// download
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
toast.success(`Downloaded time tracking data as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
|
||||
@@ -699,6 +840,49 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
<p className="text-sm text-muted-foreground break-words">No description</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Free tier limits section */}
|
||||
{!isPro && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-600">Plan Limits</h3>
|
||||
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||
<Link to="/plans">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FreeTierLimit
|
||||
current={orgCount}
|
||||
limit={FREE_TIER_LIMITS.organisationsPerUser}
|
||||
itemName="organisation"
|
||||
isPro={isPro}
|
||||
showUpgrade={false}
|
||||
/>
|
||||
<FreeTierLimit
|
||||
current={projectCount}
|
||||
limit={FREE_TIER_LIMITS.projectsPerOrganisation}
|
||||
itemName="project"
|
||||
isPro={isPro}
|
||||
showUpgrade={false}
|
||||
/>
|
||||
<FreeTierLimit
|
||||
current={issueCount}
|
||||
limit={FREE_TIER_LIMITS.issuesPerOrganisation}
|
||||
itemName="issue"
|
||||
isPro={isPro}
|
||||
showUpgrade={false}
|
||||
/>
|
||||
<FreeTierLimit
|
||||
current={memberCount}
|
||||
limit={FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
itemName="member"
|
||||
isPro={isPro}
|
||||
showUpgrade={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
|
||||
@@ -753,12 +937,52 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
|
||||
<TabsContent value="users">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-600">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</h2>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isPro && (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
From: {fromDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={fromDate}
|
||||
onSelect={(date) => date && setFromDate(date)}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
|
||||
Download CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
|
||||
Download JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||
{members.map((member) => (
|
||||
{membersWithTimeTracking.map((member) => (
|
||||
<div
|
||||
key={member.OrganisationMember.id}
|
||||
className="flex items-center justify-between p-2 border"
|
||||
@@ -770,6 +994,11 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && isPro && (
|
||||
<span className="text-sm font-mono text-muted-foreground mr-2">
|
||||
{formatDuration(member.totalTimeMs)}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin &&
|
||||
member.OrganisationMember.role !== "owner" &&
|
||||
member.User.id !== user.id && (
|
||||
@@ -803,6 +1032,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
{!isPro && (
|
||||
<div className="px-1">
|
||||
<FreeTierLimit
|
||||
current={memberCount}
|
||||
limit={FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
itemName="member"
|
||||
isPro={isPro}
|
||||
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AddMember
|
||||
organisationId={selectedOrganisation.Organisation.id}
|
||||
existingMembers={members.map((m) => m.User.username)}
|
||||
@@ -817,11 +1058,20 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
void invalidateMembers();
|
||||
}}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
title={
|
||||
!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation
|
||||
? "Free tier limited to 5 members per organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Add user <Icon icon="plus" className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1272,6 +1522,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
<TabsContent value="features">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">Features</h2>
|
||||
{!isPro && (
|
||||
<div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
Feature toggling is only available on Pro.{" "}
|
||||
<Link to="/plans" className="text-personality hover:underline">
|
||||
Upgrade to customize features.
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{Object.keys(DEFAULT_FEATURES).map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-2 p-1">
|
||||
@@ -1293,9 +1551,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
);
|
||||
await invalidateOrganisations();
|
||||
}}
|
||||
disabled={!isPro}
|
||||
color={"#ff0000"}
|
||||
/>
|
||||
<span className={"text-sm"}>{unCamelCase(feature)}</span>
|
||||
<span className={cn("text-sm", !isPro && "text-muted-foreground")}>
|
||||
{unCamelCase(feature)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
127
packages/frontend/src/components/pricing-card.tsx
Normal file
127
packages/frontend/src/components/pricing-card.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PricingTier {
|
||||
name: string;
|
||||
price: string;
|
||||
priceAnnual: string;
|
||||
period: string;
|
||||
periodAnnual: string;
|
||||
description: string;
|
||||
tagline: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
export function PricingCard({
|
||||
tier,
|
||||
billingPeriod,
|
||||
onCtaClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}: {
|
||||
tier: PricingTier;
|
||||
billingPeriod: "monthly" | "annual";
|
||||
onCtaClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border p-8 space-y-6 relative",
|
||||
tier.highlighted ? "border-2 border-personality shadow-lg scale-105" : "border-border",
|
||||
)}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="absolute -top-4 left-4 bg-personality text-background px-3 py-1 text-xs font-700">
|
||||
{tier.tagline}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-3xl font-basteleur font-700">{tier.name}</h3>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-700">
|
||||
{billingPeriod === "annual" ? tier.priceAnnual : tier.price}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingPeriod === "annual" ? tier.periodAnnual : tier.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{tier.description}</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 flex-1">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2 text-sm">
|
||||
<Icon icon="check" iconStyle={"pixel"} className="size-6 -mt-0.5" color="var(--personality)" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={tier.highlighted ? "default" : "outline"}
|
||||
className={cn(
|
||||
"font-700 py-6",
|
||||
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
|
||||
)}
|
||||
onClick={onCtaClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{loading ? "Processing..." : tier.cta}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const pricingTiers: PricingTier[] = [
|
||||
{
|
||||
name: "Starter",
|
||||
price: "£0",
|
||||
priceAnnual: "£0",
|
||||
period: "Free forever",
|
||||
periodAnnual: "Free forever",
|
||||
description: "Perfect for side projects and solo developers",
|
||||
tagline: "For solo devs and small projects",
|
||||
features: [
|
||||
"1 organisation (owned or joined)",
|
||||
"1 project",
|
||||
"5 sprints",
|
||||
"100 issues",
|
||||
"Up to 5 team members",
|
||||
"Static avatars only",
|
||||
"Pixel icon style",
|
||||
"Email support",
|
||||
],
|
||||
cta: "Get started free",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "£11.99",
|
||||
priceAnnual: "£9.99",
|
||||
period: "per user/month",
|
||||
periodAnnual: "per user/month",
|
||||
description: "For growing teams and professionals",
|
||||
tagline: "Most Popular",
|
||||
features: [
|
||||
"Everything in starter",
|
||||
"Unlimited organisations",
|
||||
"Unlimited projects",
|
||||
"Unlimited sprints",
|
||||
"Unlimited issues",
|
||||
"Animated avatars",
|
||||
"Custom icon styles",
|
||||
"Feature toggling",
|
||||
"Advanced time tracking & reports",
|
||||
"Custom issue statuses",
|
||||
"Priority email support",
|
||||
],
|
||||
cta: "Upgrade to Pro",
|
||||
highlighted: true,
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import { ProjectForm } from "@/components/project-form";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -14,6 +16,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/lib/query/hooks";
|
||||
|
||||
const FREE_TIER_PROJECT_LIMIT = 1;
|
||||
|
||||
export function ProjectSelect({
|
||||
placeholder = "Select Project",
|
||||
showLabel = false,
|
||||
@@ -29,6 +33,11 @@ export function ProjectSelect({
|
||||
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
||||
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { user } = useAuthenticatedSession();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const projectCount = projectsData.length;
|
||||
const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
|
||||
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
@@ -81,10 +90,35 @@ export function ProjectSelect({
|
||||
))}
|
||||
{projects.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
{!isPro && selectedOrganisationId && (
|
||||
<div className="px-2 py-2">
|
||||
<FreeTierLimit
|
||||
current={projectCount}
|
||||
limit={FREE_TIER_PROJECT_LIMIT}
|
||||
itemName="project"
|
||||
isPro={isPro}
|
||||
showUpgrade={isAtProjectLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProjectForm
|
||||
organisationId={selectedOrganisationId ?? undefined}
|
||||
trigger={
|
||||
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!selectedOrganisationId}>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant="ghost"
|
||||
className={"w-full"}
|
||||
disabled={!selectedOrganisationId || isAtProjectLimit}
|
||||
title={
|
||||
isAtProjectLimit
|
||||
? "Free tier limited to 1 project per organisation. Upgrade to Pro for unlimited."
|
||||
: !selectedOrganisationId
|
||||
? "Select an organisation first"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getServerURL } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_URL = "https://tnirps.ob248.com";
|
||||
const DEFAULT_URL = "https://server.sprintpm.org";
|
||||
|
||||
const formatURL = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
@@ -39,6 +43,7 @@ export function useAuthenticatedSession(): { user: UserResponse; setUser: (user:
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUserState] = useState<UserResponse | null>(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 <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>;
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{ user, setUser, isLoading, emailVerified, setEmailVerified, refreshUser }}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />;
|
||||
}
|
||||
|
||||
if (user && !emailVerified) {
|
||||
return <VerificationModal open={true} onOpenChange={() => {}} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -21,6 +22,7 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SPRINT_NAME_MAX_LENGTH = 64;
|
||||
const FREE_TIER_SPRINT_LIMIT = 5;
|
||||
|
||||
const getStartOfDay = (date: Date) => {
|
||||
const next = new Date(date);
|
||||
@@ -301,6 +303,16 @@ export function SprintForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<FreeTierLimit
|
||||
current={sprints.length}
|
||||
limit={FREE_TIER_SPRINT_LIMIT}
|
||||
itemName="sprint"
|
||||
isPro={user.plan === "pro"}
|
||||
showUpgrade={sprints.length >= FREE_TIER_SPRINT_LIMIT}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
@@ -312,7 +324,13 @@ export function SprintForm({
|
||||
disabled={
|
||||
submitting ||
|
||||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) ||
|
||||
(dateError !== "" && submitAttempted)
|
||||
(dateError !== "" && submitAttempted) ||
|
||||
(!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT)
|
||||
}
|
||||
title={
|
||||
!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT
|
||||
? `Free tier limited to ${FREE_TIER_SPRINT_LIMIT} sprints per project. Upgrade to Pro for unlimited sprints.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import Account from "@/components/account";
|
||||
import { IssueForm } from "@/components/issue-form";
|
||||
import LogOutButton from "@/components/log-out-button";
|
||||
@@ -11,6 +11,7 @@ import { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { SprintForm } from "@/components/sprint-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -122,6 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
{user.plan !== "pro" && (
|
||||
<Button asChild className="bg-personality hover:bg-personality/90 text-background font-600">
|
||||
<Link to="/plans">Upgrade</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-sm">
|
||||
<SmallUserDisplay user={user} />
|
||||
|
||||
131
packages/frontend/src/components/ui/alert-dialog.tsx
Normal file
131
packages/frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import type * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn("fixed inset-0 z-50 bg-black/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%]",
|
||||
"z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"gap-4 border p-4 shadow-lg duration-200 outline-none w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogActionProps = React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Omit<React.ComponentProps<typeof Button>, "asChild">;
|
||||
|
||||
function AlertDialogAction({ className, ...props }: AlertDialogActionProps) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action asChild>
|
||||
<Button className={className} {...props} />
|
||||
</AlertDialogPrimitive.Action>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogCancelProps = React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Omit<React.ComponentProps<typeof Button>, "asChild">;
|
||||
|
||||
function AlertDialogCancel({ className, ...props }: AlertDialogCancelProps) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel asChild>
|
||||
<Button variant="outline" className={className} {...props} />
|
||||
</AlertDialogPrimitive.Cancel>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
69
packages/frontend/src/components/ui/input-otp.tsx
Normal file
69
packages/frontend/src/components/ui/input-otp.tsx
Normal file
@@ -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<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />;
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -8,6 +9,37 @@ import { useUploadAvatar } from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function isAnimatedGIF(file: File): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result as ArrayBuffer;
|
||||
const arr = new Uint8Array(buffer);
|
||||
// check for GIF89a or GIF87a header
|
||||
const header = String.fromCharCode(...arr.slice(0, 6));
|
||||
if (header !== "GIF89a" && header !== "GIF87a") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
// look for multiple images (animation indicator)
|
||||
// GIFs have image descriptors starting with 0x2C
|
||||
// and graphic control extensions starting with 0x21 0xF9
|
||||
let frameCount = 0;
|
||||
let i = 6; // skip header
|
||||
while (i < arr.length - 1) {
|
||||
if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
|
||||
// graphic control extension - indicates animation frame
|
||||
frameCount++;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
resolve(frameCount > 1);
|
||||
};
|
||||
reader.onerror = () => resolve(false);
|
||||
reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
|
||||
});
|
||||
}
|
||||
|
||||
export function UploadAvatar({
|
||||
name,
|
||||
username,
|
||||
@@ -24,6 +56,7 @@ export function UploadAvatar({
|
||||
skipOrgCheck?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { user } = useSession();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -34,6 +67,22 @@ export function UploadAvatar({
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// check for animated GIF for free users
|
||||
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.");
|
||||
toast.error("Animated avatars are only available on Pro. Upgrade to upload animated avatars.", {
|
||||
dismissible: false,
|
||||
});
|
||||
// reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -50,10 +99,26 @@ export function UploadAvatar({
|
||||
setError(message);
|
||||
setUploading(false);
|
||||
|
||||
// check if the error is about animated avatars for free users
|
||||
if (message.toLowerCase().includes("animated") && message.toLowerCase().includes("pro")) {
|
||||
toast.error(
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Animated avatars are only available on Pro.</span>
|
||||
<a href="/plans" className="text-personality hover:underline">
|
||||
Upgrade to Pro
|
||||
</a>
|
||||
</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Error uploading avatar: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
104
packages/frontend/src/components/verification-modal.tsx
Normal file
104
packages/frontend/src/components/verification-modal.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Verify your email</DialogTitle>
|
||||
<DialogDescription>
|
||||
We've sent a 6-digit verification code to your email. Enter it below to complete your
|
||||
registration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
disabled={verifyMutation.isPending}
|
||||
autoFocus
|
||||
className="gap-2"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className="w-14 h-16 text-2xl" />
|
||||
<InputOTPSlot index={1} className="w-14 h-16 text-2xl" />
|
||||
<InputOTPSlot index={2} className="w-14 h-16 text-2xl" />
|
||||
<InputOTPSlot index={3} className="w-14 h-16 text-2xl" />
|
||||
<InputOTPSlot index={4} className="w-14 h-16 text-2xl" />
|
||||
<InputOTPSlot index={5} className="w-14 h-16 text-2xl" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
{resendSuccess && <p className="text-sm text-green-600 text-center">Verification code sent!</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={code.length !== 6 || verifyMutation.isPending} className="w-full">
|
||||
{verifyMutation.isPending ? "Verifying..." : "Verify email"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleResend}
|
||||
disabled={resendMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{resendMutation.isPending ? "Sending..." : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,5 +4,7 @@ export * from "@/lib/query/hooks/issues";
|
||||
export * from "@/lib/query/hooks/organisations";
|
||||
export * from "@/lib/query/hooks/projects";
|
||||
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";
|
||||
|
||||
@@ -14,6 +14,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export interface MemberTimeTrackingSession {
|
||||
id: number;
|
||||
userId: number;
|
||||
issueId: number;
|
||||
issueNumber: number;
|
||||
projectKey: string;
|
||||
timestamps: string[];
|
||||
endedAt: string | null;
|
||||
createdAt: string | null;
|
||||
workTimeMs: number;
|
||||
breakTimeMs: number;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function useOrganisations() {
|
||||
return useQuery<OrganisationResponse[]>({
|
||||
queryKey: queryKeys.organisations.byUser(),
|
||||
@@ -39,6 +53,23 @@ export function useOrganisationMembers(organisationId?: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrganisationMemberTimeTracking(organisationId?: number | null, fromDate?: Date) {
|
||||
return useQuery<MemberTimeTrackingSession[]>({
|
||||
queryKey: queryKeys.organisations.memberTimeTracking(organisationId ?? 0, fromDate?.toISOString()),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.organisationMemberTimeTracking({
|
||||
query: {
|
||||
organisationId: organisationId ?? 0,
|
||||
fromDate: fromDate,
|
||||
},
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as MemberTimeTrackingSession[];
|
||||
},
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
62
packages/frontend/src/lib/query/hooks/subscriptions.ts
Normal file
62
packages/frontend/src/lib/query/hooks/subscriptions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
CancelSubscriptionResponse,
|
||||
CreateCheckoutSessionRequest,
|
||||
CreateCheckoutSessionResponse,
|
||||
CreatePortalSessionResponse,
|
||||
GetSubscriptionResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useSubscription() {
|
||||
return useQuery<GetSubscriptionResponse>({
|
||||
queryKey: queryKeys.subscription.current(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.subscriptionGet();
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? { subscription: null }) as GetSubscriptionResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCheckoutSession() {
|
||||
return useMutation<CreateCheckoutSessionResponse, Error, CreateCheckoutSessionRequest>({
|
||||
mutationKey: ["subscription", "checkout"],
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.subscriptionCreateCheckoutSession({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create checkout session");
|
||||
return data as CreateCheckoutSessionResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePortalSession() {
|
||||
return useMutation<CreatePortalSessionResponse, Error>({
|
||||
mutationKey: ["subscription", "portal"],
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await apiClient.subscriptionCreatePortalSession({ body: {} });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create portal session");
|
||||
return data as CreatePortalSessionResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelSubscription() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<CancelSubscriptionResponse, Error>({
|
||||
mutationKey: ["subscription", "cancel"],
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await apiClient.subscriptionCancel({ body: {} });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to cancel subscription");
|
||||
return data as CancelSubscriptionResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.subscription.current() });
|
||||
},
|
||||
});
|
||||
}
|
||||
22
packages/frontend/src/lib/query/hooks/verification.ts
Normal file
22
packages/frontend/src/lib/query/hooks/verification.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useVerifyEmail() {
|
||||
return useMutation<void, Error, { code: string }>({
|
||||
mutationKey: ["verification", "verify"],
|
||||
mutationFn: async ({ code }) => {
|
||||
const { error } = await apiClient.authVerifyEmail({ body: { code } });
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendVerification() {
|
||||
return useMutation<void, Error>({
|
||||
mutationKey: ["verification", "resend"],
|
||||
mutationFn: async () => {
|
||||
const { error } = await apiClient.authResendVerification({ body: {} });
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export const queryKeys = {
|
||||
all: ["organisations"] as const,
|
||||
byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
|
||||
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
|
||||
memberTimeTracking: (orgId: number, fromDate?: string) =>
|
||||
[...queryKeys.organisations.all, orgId, "member-time-tracking", fromDate ?? "all"] as const,
|
||||
},
|
||||
projects: {
|
||||
all: ["projects"] as const,
|
||||
@@ -37,4 +39,8 @@ export const queryKeys = {
|
||||
all: ["users"] as const,
|
||||
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const,
|
||||
},
|
||||
subscription: {
|
||||
all: ["subscription"] as const,
|
||||
current: () => [...queryKeys.subscription.all, "current"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getServerURL() {
|
||||
let serverURL =
|
||||
localStorage.getItem("serverURL") || // user-defined server URL
|
||||
ENV_SERVER_URL || // environment variable
|
||||
"https://tnirps.ob248.com"; // fallback
|
||||
"https://server.sprintpm.org"; // fallback
|
||||
if (serverURL.endsWith("/")) {
|
||||
serverURL = serverURL.slice(0, -1);
|
||||
}
|
||||
@@ -69,3 +69,19 @@ export const isLight = (hex: string): boolean => {
|
||||
export const unCamelCase = (str: string): string => {
|
||||
return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number): string => {
|
||||
if (ms === 0) return "0s";
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 || (hours === 0 && minutes === 0)) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(" ") || "0s";
|
||||
};
|
||||
|
||||
@@ -8,10 +8,12 @@ import { SelectionProvider } from "@/components/selection-provider";
|
||||
import { RequireAuth, SessionProvider } from "@/components/session-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import BoringStuff from "@/pages/BoringStuff";
|
||||
import Font from "@/pages/Font";
|
||||
import Issues from "@/pages/Issues";
|
||||
import Landing from "@/pages/Landing";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import Plans from "@/pages/Plans";
|
||||
import Test from "@/pages/Test";
|
||||
import Timeline from "@/pages/Timeline";
|
||||
|
||||
@@ -26,8 +28,17 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
{/* public routes */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/font" element={<Font />} />
|
||||
<Route path="/the-boring-stuff" element={<BoringStuff />} />
|
||||
|
||||
{/* authed routes */}
|
||||
<Route
|
||||
path="/plans"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Plans />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/issues"
|
||||
element={
|
||||
@@ -55,9 +66,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<ActiveTimersOverlay />
|
||||
</SelectionProvider>
|
||||
</BrowserRouter>
|
||||
<ActiveTimersOverlay />
|
||||
<Toaster visibleToasts={1} duration={2000} />
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
|
||||
122
packages/frontend/src/pages/BoringStuff.tsx
Normal file
122
packages/frontend/src/pages/BoringStuff.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
export default function BoringStuff() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-md">
|
||||
<div className="w-full flex h-14 items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="Sprint" className="size-12 -mt-0.5" />
|
||||
<span className="text-3xl font-basteleur font-700 transition-colors -mt-0.5">Sprint</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-4 py-12">
|
||||
<div className="max-w-3xl mx-auto space-y-12">
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-4xl font-basteleur font-700">The Boring Stuff</h1>
|
||||
<p className="text-muted-foreground">Let's keep it short.</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6" id="privacy">
|
||||
<h2 className="text-2xl font-basteleur font-700">Privacy Policy</h2>
|
||||
<div className="space-y-4 text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">What we store:</strong> We store your email, name, and any
|
||||
data you create (issues, projects, time tracking).
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">How we use it:</strong> Only your email is used for
|
||||
subscription alerts and newsletters (you can unsubscribe).
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Where it's stored:</strong> Data is stored on secure
|
||||
servers.
|
||||
</p>
|
||||
<p>
|
||||
{/* <strong className="text-foreground">Your rights:</strong> You can export or delete your data
|
||||
anytime. Just email us at privacy@sprintpm.org. */}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Cookies:</strong> We use essential cookies for
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6" id="terms">
|
||||
<h2 className="text-2xl font-basteleur font-700">Terms of Service</h2>
|
||||
<div className="space-y-4 text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">The basics:</strong> Sprint is a project management tool.
|
||||
Use it to organise work, track issues, and manage time. Don't use it for illegal stuff.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Your account:</strong> You're responsible for keeping your
|
||||
login details secure. Don't share your account.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Payments:</strong> Pro plans are billed monthly or
|
||||
annually. Cancel anytime from your account settings. No refunds for partial months.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Service availability:</strong> We aim for 99.9% uptime but
|
||||
can't guarantee it. We may occasionally need downtime for maintenance.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Termination:</strong> We may suspend accounts that violate
|
||||
these terms.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Changes:</strong> We'll notify you of significant changes
|
||||
to these terms via email.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6" id="contact">
|
||||
<h2 className="text-2xl font-basteleur font-700">Questions?</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Email us at{" "}
|
||||
<a href="mailto:support@sprintpm.org" className="text-personality hover:underline">
|
||||
support@sprintpm.org
|
||||
</a>{" "}
|
||||
- we'll get back to you within 24 hours.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground">Last updated: January 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-1 border-t">
|
||||
<span className="font-300 text-lg text-muted-foreground">
|
||||
Built by{" "}
|
||||
<a
|
||||
href="https://ob248.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-personality font-700"
|
||||
>
|
||||
Oliver Bryan
|
||||
</a>
|
||||
</span>
|
||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="/oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LoginModal } from "@/components/login-modal";
|
||||
import { PricingCard, pricingTiers } from "@/components/pricing-card";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,57 +9,7 @@ import Icon from "@/components/ui/icon";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const pricingTiers = [
|
||||
{
|
||||
name: "Starter",
|
||||
price: "£0",
|
||||
priceAnnual: "£0",
|
||||
period: "Free forever",
|
||||
periodAnnual: "Free forever",
|
||||
description: "Perfect for side projects and solo developers",
|
||||
tagline: "For solo devs and small projects",
|
||||
features: [
|
||||
"1 organisation (owned or joined)",
|
||||
"1 project",
|
||||
"100 issues",
|
||||
"Up to 5 team members",
|
||||
"Email support",
|
||||
],
|
||||
cta: "Get started free",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "£11.99",
|
||||
priceAnnual: "£9.99",
|
||||
period: "per user/month",
|
||||
periodAnnual: "per user/month",
|
||||
description: "For growing teams and professionals",
|
||||
tagline: "Most Popular",
|
||||
features: [
|
||||
"Everything in starter",
|
||||
"Unlimited organisations",
|
||||
"Unlimited projects",
|
||||
"Unlimited issues",
|
||||
"Advanced time tracking & reports",
|
||||
"Custom issue statuses",
|
||||
"Priority email support",
|
||||
],
|
||||
cta: "Try pro free for 14 days",
|
||||
highlighted: true,
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "Can I switch plans?",
|
||||
answer:
|
||||
"Yes, you can upgrade or downgrade at any time. Changes take effect immediately, and we'll prorate any charges.",
|
||||
},
|
||||
{
|
||||
question: "Is there a free trial?",
|
||||
answer: "Yes, pro plan includes a 14-day free trial with full access. No credit card required to start.",
|
||||
},
|
||||
{
|
||||
question: "What payment methods do you accept?",
|
||||
answer: "We accept all major credit cards.",
|
||||
@@ -68,11 +19,6 @@ const faqs = [
|
||||
answer:
|
||||
"Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your billing automatically.",
|
||||
},
|
||||
{
|
||||
question: "What happens when my trial ends?",
|
||||
answer:
|
||||
"You'll automatically downgrade to the free starter plan. No charges unless you actively upgrade to pro.",
|
||||
},
|
||||
{
|
||||
question: "Can I cancel anytime?",
|
||||
answer:
|
||||
@@ -86,7 +32,7 @@ const faqs = [
|
||||
|
||||
export default function Landing() {
|
||||
const { user, isLoading } = useSession();
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("monthly");
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -163,7 +109,7 @@ export default function Landing() {
|
||||
) : (
|
||||
<>
|
||||
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
|
||||
Start free trial
|
||||
Get started
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
|
||||
<a href="#pricing">See pricing</a>
|
||||
@@ -172,7 +118,7 @@ export default function Landing() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">No credit card required · Full access for 14 days</p>
|
||||
<p className="text-sm text-muted-foreground">Free forever · Upgrade when you need more</p>
|
||||
</div>
|
||||
|
||||
{/* problem section */}
|
||||
@@ -323,57 +269,12 @@ export default function Landing() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl">
|
||||
{pricingTiers.map((tier) => (
|
||||
<div
|
||||
<PricingCard
|
||||
key={tier.name}
|
||||
className={cn(
|
||||
"flex flex-col border p-8 space-y-6 relative",
|
||||
tier.highlighted ? "border-2 border-personality shadow-lg scale-105" : "border-border",
|
||||
)}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="absolute -top-4 left-4 bg-personality text-background px-3 py-1 text-xs font-700">
|
||||
{tier.tagline}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-3xl font-basteleur font-700">{tier.name}</h3>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-700">
|
||||
{billingPeriod === "annual" ? tier.priceAnnual : tier.price}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingPeriod === "annual" ? tier.periodAnnual : tier.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{tier.description}</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 flex-1">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2 text-sm">
|
||||
<Icon
|
||||
icon="check"
|
||||
iconStyle={"pixel"}
|
||||
className="size-6 -mt-0.5"
|
||||
color="var(--personality)"
|
||||
tier={tier}
|
||||
billingPeriod={billingPeriod}
|
||||
onCtaClick={() => setLoginModalOpen(true)}
|
||||
/>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={tier.highlighted ? "default" : "outline"}
|
||||
className={cn(
|
||||
"font-700 py-6",
|
||||
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
|
||||
)}
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
{tier.cta}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -391,8 +292,8 @@ export default function Landing() {
|
||||
className="size-8"
|
||||
color="var(--personality)"
|
||||
/>
|
||||
<p className="font-700">No Card Required</p>
|
||||
<p className="text-sm text-muted-foreground">Start your trial instantly</p>
|
||||
<p className="font-700">Free Starter Plan</p>
|
||||
<p className="text-sm text-muted-foreground">Get started instantly</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
|
||||
@@ -456,12 +357,12 @@ export default function Landing() {
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
|
||||
Start your free trial
|
||||
Get started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No credit card required · 14-day free trial · Cancel anytime
|
||||
Free forever · Upgrade when you need more · Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,7 +370,8 @@ export default function Landing() {
|
||||
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-1 border-t">
|
||||
<footer className="flex flex-col items-center gap-2 py-1 border-t">
|
||||
<div className="flex justify-center gap-2 items-center">
|
||||
<span className="font-300 text-lg text-muted-foreground">
|
||||
Built by{" "}
|
||||
<a
|
||||
@@ -484,6 +386,13 @@ export default function Landing() {
|
||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<Link
|
||||
to="/the-boring-stuff"
|
||||
className="text-sm text-muted-foreground hover:text-personality transition-colors"
|
||||
>
|
||||
The boring stuff — Privacy Policy & ToS
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
304
packages/frontend/src/pages/Plans.tsx
Normal file
304
packages/frontend/src/pages/Plans.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { LoginModal } from "@/components/login-modal";
|
||||
import { PricingCard, pricingTiers } from "@/components/pricing-card";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCancelSubscription,
|
||||
useCreateCheckoutSession,
|
||||
useCreatePortalSession,
|
||||
useSubscription,
|
||||
} from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Plans() {
|
||||
const { user, isLoading } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [processingTier, setProcessingTier] = useState<string | null>(null);
|
||||
|
||||
const { data: subscriptionData } = useSubscription();
|
||||
const createCheckoutSession = useCreateCheckoutSession();
|
||||
const createPortalSession = useCreatePortalSession();
|
||||
const cancelSubscription = useCancelSubscription();
|
||||
|
||||
const subscription = subscriptionData?.subscription ?? null;
|
||||
const isProUser =
|
||||
user?.plan === "pro" || subscription?.status === "active" || subscription?.status === "trialing";
|
||||
const isCancellationScheduled = Boolean(subscription?.cancelAtPeriodEnd);
|
||||
const isCanceled = subscription?.status === "canceled";
|
||||
const cancellationEndDate = useMemo(() => {
|
||||
if (!subscription?.currentPeriodEnd) return null;
|
||||
const date = new Date(subscription.currentPeriodEnd);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return format(date, "d MMM yyyy");
|
||||
}, [subscription?.currentPeriodEnd]);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
|
||||
const handleTierAction = async (tierName: string) => {
|
||||
if (!user) {
|
||||
setLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tierName === "Pro") {
|
||||
if (isProUser) {
|
||||
// open customer portal
|
||||
setProcessingTier(tierName);
|
||||
try {
|
||||
const result = await createPortalSession.mutateAsync();
|
||||
if (result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
setProcessingTier(null);
|
||||
}
|
||||
} catch {
|
||||
setProcessingTier(null);
|
||||
}
|
||||
} else {
|
||||
// start checkout
|
||||
setProcessingTier(tierName);
|
||||
try {
|
||||
const result = await createCheckoutSession.mutateAsync({ billingPeriod });
|
||||
if (result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
setProcessingTier(null);
|
||||
}
|
||||
} catch {
|
||||
setProcessingTier(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
// starter tier - just go to issues if not already there
|
||||
if (tierName === "Starter") {
|
||||
navigate("/issues");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
setCancelError(null);
|
||||
try {
|
||||
await cancelSubscription.mutateAsync();
|
||||
setCancelDialogOpen(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "failed to cancel subscription";
|
||||
setCancelError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// modify pricing tiers based on user's current plan
|
||||
const modifiedTiers = pricingTiers.map((tier) => {
|
||||
const isCurrentPlan = tier.name === "Pro" && isProUser;
|
||||
const isStarterCurrent = tier.name === "Starter" && !!user && !isProUser;
|
||||
|
||||
return {
|
||||
...tier,
|
||||
highlighted: isCurrentPlan || (!isProUser && tier.name === "Pro"),
|
||||
cta: isCurrentPlan
|
||||
? "Manage subscription"
|
||||
: isStarterCurrent
|
||||
? "Current plan"
|
||||
: tier.name === "Pro"
|
||||
? "Upgrade to Pro"
|
||||
: tier.cta,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-md">
|
||||
<div className="w-full flex h-14 items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="Sprint" className="size-12 -mt-0.5" />
|
||||
<span className="text-3xl font-basteleur font-700 transition-colors -mt-0.5">Sprint</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isLoading && user ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/issues">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setLoginModalOpen(true)}>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center py-16 pt-14 px-4">
|
||||
<div className="max-w-6xl w-full space-y-16">
|
||||
<div className="text-center space-y-6">
|
||||
<h1 className="text-5xl font-basteleur font-700">
|
||||
{user ? "Choose your plan" : "Simple, transparent pricing"}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
{user
|
||||
? isProUser
|
||||
? "You are currently on the Pro plan. Manage your subscription or switch plans below."
|
||||
: "You are currently on the Starter plan. Upgrade to Pro for unlimited access."
|
||||
: "Choose the plan that fits your team. Scale as you grow."}
|
||||
</p>
|
||||
|
||||
{/* billing toggle */}
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod("monthly")}
|
||||
className={cn(
|
||||
"text-lg transition-colors",
|
||||
billingPeriod === "monthly" ? "text-foreground font-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
monthly
|
||||
</button>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={billingPeriod === "annual"}
|
||||
onCheckedChange={(checked) => setBillingPeriod(checked ? "annual" : "monthly")}
|
||||
className="bg-border data-[state=checked]:bg-border! data-[state=unchecked]:bg-border!"
|
||||
thumbClassName="bg-personality dark:bg-personality data-[state=checked]:bg-personality! data-[state=unchecked]:bg-personality!"
|
||||
aria-label="toggle billing period"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingPeriod("annual")}
|
||||
className={cn(
|
||||
"text-lg transition-colors",
|
||||
billingPeriod === "annual" ? "text-foreground font-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
annual
|
||||
</button>
|
||||
<span className="text-sm px-3 py-1 bg-personality/10 text-personality rounded-full font-600">
|
||||
Save 17%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl mx-auto">
|
||||
{modifiedTiers.map((tier) => (
|
||||
<PricingCard
|
||||
key={tier.name}
|
||||
tier={tier}
|
||||
billingPeriod={billingPeriod}
|
||||
onCtaClick={() => handleTierAction(tier.name)}
|
||||
disabled={processingTier !== null || tier.name === "Starter"}
|
||||
loading={processingTier === tier.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && isProUser && (
|
||||
<div className="w-full max-w-4xl mx-auto border p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="font-700">Cancel subscription</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isCancellationScheduled || isCanceled
|
||||
? `Cancelled, benefits end on ${cancellationEndDate ?? "your billing end date"}.`
|
||||
: "Canceling will keep access until the end of your billing period."}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={cancelDialogOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setCancelDialogOpen(open);
|
||||
if (!open) setCancelError(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={cancelSubscription.isPending || isCancellationScheduled || isCanceled}
|
||||
>
|
||||
{isCancellationScheduled || isCanceled
|
||||
? "Cancellation scheduled"
|
||||
: "Cancel subscription"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel subscription?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You will keep Pro access until the end of your current billing period.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep subscription</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleCancelSubscription}>
|
||||
{cancelSubscription.isPending ? "Canceling..." : "Confirm cancel"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
{cancelError && <p className="text-sm text-destructive">{cancelError}</p>}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* trust signals */}
|
||||
<div className="grid md:grid-cols-3 gap-8 w-full border-t pt-16 pb-4 max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<Icon icon="eyeClosed" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
|
||||
<p className="font-700">Secure & Encrypted</p>
|
||||
<p className="text-sm text-muted-foreground">Your data is safe with us</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<Icon
|
||||
icon="creditCardDelete"
|
||||
iconStyle={"pixel"}
|
||||
className="size-8"
|
||||
color="var(--personality)"
|
||||
/>
|
||||
<p className="font-700">Free Starter Plan</p>
|
||||
<p className="text-sm text-muted-foreground">Get started instantly</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
|
||||
<p className="font-700">Money Back Guarantee</p>
|
||||
<p className="text-sm text-muted-foreground">30-day no-risk policy</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-4xl mx-auto border-t pt-4 pb-2 text-center">
|
||||
<Link
|
||||
to="/the-boring-stuff"
|
||||
className="text-sm text-muted-foreground hover:text-personality transition-colors"
|
||||
>
|
||||
The boring stuff — Privacy Policy & ToS
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export default defineConfig(async () => ({
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
allowedHosts: ["sprint.ob248.com"],
|
||||
allowedHosts: ["sprint.ob248.com", "sprintpm.org"],
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ORG_NAME_MAX_LENGTH,
|
||||
ORG_SLUG_MAX_LENGTH,
|
||||
PROJECT_NAME_MAX_LENGTH,
|
||||
USER_EMAIL_MAX_LENGTH,
|
||||
USER_NAME_MAX_LENGTH,
|
||||
USER_USERNAME_MAX_LENGTH,
|
||||
} from "./constants";
|
||||
@@ -40,6 +41,7 @@ export const RegisterRequestSchema = z.object({
|
||||
.min(1, "Username is required")
|
||||
.max(USER_USERNAME_MAX_LENGTH)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
email: z.string().min(1, "Email is required").email("Invalid email address").max(USER_EMAIL_MAX_LENGTH),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
@@ -58,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<typeof AuthResponseSchema>;
|
||||
|
||||
// email verification schemas
|
||||
|
||||
export const VerifyEmailRequestSchema = z.object({
|
||||
code: z.string().length(6, "Verification code must be 6 digits"),
|
||||
});
|
||||
|
||||
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequestSchema>;
|
||||
|
||||
// issue schemas
|
||||
|
||||
export const IssueCreateRequestSchema = z.object({
|
||||
@@ -227,6 +238,13 @@ export const OrgMembersQuerySchema = z.object({
|
||||
|
||||
export type OrgMembersQuery = z.infer<typeof OrgMembersQuerySchema>;
|
||||
|
||||
export const OrgMemberTimeTrackingQuerySchema = z.object({
|
||||
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
|
||||
fromDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export type OrgMemberTimeTrackingQuery = z.infer<typeof OrgMemberTimeTrackingQuerySchema>;
|
||||
|
||||
export const OrgAddMemberRequestSchema = z.object({
|
||||
organisationId: z.number().int().positive("organisationId must be a positive integer"),
|
||||
userId: z.number().int().positive("userId must be a positive integer"),
|
||||
@@ -412,6 +430,7 @@ export const UserResponseSchema = z.object({
|
||||
username: z.string(),
|
||||
avatarURL: z.string().nullable(),
|
||||
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
|
||||
plan: z.string().nullable().optional(),
|
||||
createdAt: z.string().nullable().optional(),
|
||||
updatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
@@ -594,3 +613,54 @@ export const SuccessResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
|
||||
|
||||
// subscription schemas
|
||||
|
||||
export const CreateCheckoutSessionRequestSchema = z.object({
|
||||
billingPeriod: z.enum(["monthly", "annual"]),
|
||||
});
|
||||
|
||||
export type CreateCheckoutSessionRequest = z.infer<typeof CreateCheckoutSessionRequestSchema>;
|
||||
|
||||
export const CreateCheckoutSessionResponseSchema = z.object({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type CreateCheckoutSessionResponse = z.infer<typeof CreateCheckoutSessionResponseSchema>;
|
||||
|
||||
export const CreatePortalSessionResponseSchema = z.object({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type CreatePortalSessionResponse = z.infer<typeof CreatePortalSessionResponseSchema>;
|
||||
|
||||
export const SubscriptionRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
userId: z.number(),
|
||||
stripeCustomerId: z.string().nullable(),
|
||||
stripeSubscriptionId: z.string().nullable(),
|
||||
stripeSubscriptionItemId: z.string().nullable(),
|
||||
stripePriceId: z.string().nullable(),
|
||||
status: z.string(),
|
||||
currentPeriodStart: z.string().nullable().optional(),
|
||||
currentPeriodEnd: z.string().nullable().optional(),
|
||||
cancelAtPeriodEnd: z.boolean(),
|
||||
trialEnd: z.string().nullable().optional(),
|
||||
quantity: z.number(),
|
||||
createdAt: z.string().nullable().optional(),
|
||||
updatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type SubscriptionRecord = z.infer<typeof SubscriptionRecordSchema>;
|
||||
|
||||
export const GetSubscriptionResponseSchema = z.object({
|
||||
subscription: SubscriptionRecordSchema.nullable(),
|
||||
});
|
||||
|
||||
export type GetSubscriptionResponse = z.infer<typeof GetSubscriptionResponseSchema>;
|
||||
|
||||
export const CancelSubscriptionResponseSchema = z.object({
|
||||
subscription: SubscriptionRecordSchema,
|
||||
});
|
||||
|
||||
export type CancelSubscriptionResponse = z.infer<typeof CancelSubscriptionResponseSchema>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const USER_NAME_MAX_LENGTH = 64;
|
||||
export const USER_USERNAME_MAX_LENGTH = 32;
|
||||
export const USER_EMAIL_MAX_LENGTH = 256;
|
||||
|
||||
export const ORG_NAME_MAX_LENGTH = 64;
|
||||
export const ORG_DESCRIPTION_MAX_LENGTH = 1024;
|
||||
|
||||
@@ -3,6 +3,11 @@ import { z } from "zod";
|
||||
import {
|
||||
ApiErrorSchema,
|
||||
AuthResponseSchema,
|
||||
CancelSubscriptionResponseSchema,
|
||||
CreateCheckoutSessionRequestSchema,
|
||||
CreateCheckoutSessionResponseSchema,
|
||||
CreatePortalSessionResponseSchema,
|
||||
GetSubscriptionResponseSchema,
|
||||
IssueByIdQuerySchema,
|
||||
IssueCommentCreateRequestSchema,
|
||||
IssueCommentDeleteRequestSchema,
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
OrgCreateRequestSchema,
|
||||
OrgDeleteRequestSchema,
|
||||
OrgMembersQuerySchema,
|
||||
OrgMemberTimeTrackingQuerySchema,
|
||||
OrgRemoveMemberRequestSchema,
|
||||
OrgUpdateMemberRoleRequestSchema,
|
||||
OrgUpdateRequestSchema,
|
||||
@@ -171,6 +177,7 @@ export const apiContract = c.router({
|
||||
responses: {
|
||||
200: z.object({ avatarURL: z.string() }),
|
||||
400: ApiErrorSchema,
|
||||
403: ApiErrorSchema,
|
||||
},
|
||||
headers: csrfHeaderSchema,
|
||||
},
|
||||
@@ -379,6 +386,30 @@ export const apiContract = c.router({
|
||||
200: z.array(OrganisationMemberResponseSchema),
|
||||
},
|
||||
},
|
||||
organisationMemberTimeTracking: {
|
||||
method: "GET",
|
||||
path: "/organisation/member-time-tracking",
|
||||
query: OrgMemberTimeTrackingQuerySchema,
|
||||
responses: {
|
||||
200: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
userId: z.number(),
|
||||
issueId: z.number(),
|
||||
issueNumber: z.number(),
|
||||
projectKey: z.string(),
|
||||
timestamps: z.array(z.string()),
|
||||
endedAt: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
workTimeMs: z.number(),
|
||||
breakTimeMs: z.number(),
|
||||
isRunning: z.boolean(),
|
||||
}),
|
||||
),
|
||||
403: ApiErrorSchema,
|
||||
404: ApiErrorSchema,
|
||||
},
|
||||
},
|
||||
organisationRemoveMember: {
|
||||
method: "POST",
|
||||
path: "/organisation/remove-member",
|
||||
@@ -575,6 +606,73 @@ export const apiContract = c.router({
|
||||
200: z.array(timerListItemResponseSchema),
|
||||
},
|
||||
},
|
||||
|
||||
subscriptionCreateCheckoutSession: {
|
||||
method: "POST",
|
||||
path: "/subscription/create-checkout-session",
|
||||
body: CreateCheckoutSessionRequestSchema,
|
||||
responses: {
|
||||
200: CreateCheckoutSessionResponseSchema,
|
||||
400: ApiErrorSchema,
|
||||
404: ApiErrorSchema,
|
||||
500: ApiErrorSchema,
|
||||
},
|
||||
headers: csrfHeaderSchema,
|
||||
},
|
||||
subscriptionCreatePortalSession: {
|
||||
method: "POST",
|
||||
path: "/subscription/create-portal-session",
|
||||
body: emptyBodySchema,
|
||||
responses: {
|
||||
200: CreatePortalSessionResponseSchema,
|
||||
404: ApiErrorSchema,
|
||||
500: ApiErrorSchema,
|
||||
},
|
||||
headers: csrfHeaderSchema,
|
||||
},
|
||||
subscriptionCancel: {
|
||||
method: "POST",
|
||||
path: "/subscription/cancel",
|
||||
body: emptyBodySchema,
|
||||
responses: {
|
||||
200: CancelSubscriptionResponseSchema,
|
||||
404: ApiErrorSchema,
|
||||
500: ApiErrorSchema,
|
||||
},
|
||||
headers: csrfHeaderSchema,
|
||||
},
|
||||
subscriptionGet: {
|
||||
method: "GET",
|
||||
path: "/subscription/get",
|
||||
responses: {
|
||||
200: GetSubscriptionResponseSchema,
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export type {
|
||||
ApiError,
|
||||
AuthResponse,
|
||||
CancelSubscriptionResponse,
|
||||
CreateCheckoutSessionRequest,
|
||||
CreateCheckoutSessionResponse,
|
||||
CreatePortalSessionResponse,
|
||||
GetSubscriptionResponse,
|
||||
IssueByIdQuery,
|
||||
IssueCommentCreateRequest,
|
||||
IssueCommentDeleteRequest,
|
||||
@@ -25,6 +30,7 @@ export type {
|
||||
OrgCreateRequest,
|
||||
OrgDeleteRequest,
|
||||
OrgMembersQuery,
|
||||
OrgMemberTimeTrackingQuery,
|
||||
OrgRemoveMemberRequest,
|
||||
OrgUpdateMemberRoleRequest,
|
||||
OrgUpdateRequest,
|
||||
@@ -45,6 +51,7 @@ export type {
|
||||
SprintsByProjectQuery,
|
||||
SprintUpdateRequest,
|
||||
StatusCountResponse,
|
||||
SubscriptionRecord as SubscriptionResponse,
|
||||
SuccessResponse,
|
||||
TimerEndRequest,
|
||||
TimerGetQuery,
|
||||
@@ -56,11 +63,17 @@ export type {
|
||||
UserByUsernameQuery,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
VerifyEmailRequest,
|
||||
} from "./api-schemas";
|
||||
// API schemas
|
||||
export {
|
||||
ApiErrorSchema,
|
||||
AuthResponseSchema,
|
||||
CancelSubscriptionResponseSchema,
|
||||
CreateCheckoutSessionRequestSchema,
|
||||
CreateCheckoutSessionResponseSchema,
|
||||
CreatePortalSessionResponseSchema,
|
||||
GetSubscriptionResponseSchema,
|
||||
IssueByIdQuerySchema,
|
||||
IssueCommentCreateRequestSchema,
|
||||
IssueCommentDeleteRequestSchema,
|
||||
@@ -87,6 +100,7 @@ export {
|
||||
OrgCreateRequestSchema,
|
||||
OrgDeleteRequestSchema,
|
||||
OrgMembersQuerySchema,
|
||||
OrgMemberTimeTrackingQuerySchema,
|
||||
OrgRemoveMemberRequestSchema,
|
||||
OrgUpdateMemberRoleRequestSchema,
|
||||
OrgUpdateRequestSchema,
|
||||
@@ -108,6 +122,7 @@ export {
|
||||
SprintsByProjectQuerySchema,
|
||||
SprintUpdateRequestSchema,
|
||||
StatusCountResponseSchema,
|
||||
SubscriptionRecordSchema as SubscriptionRecordApiSchema,
|
||||
SuccessResponseSchema,
|
||||
TimerEndRequestSchema,
|
||||
TimerGetQuerySchema,
|
||||
@@ -119,6 +134,7 @@ export {
|
||||
UserByUsernameQuerySchema,
|
||||
UserResponseSchema,
|
||||
UserUpdateRequestSchema,
|
||||
VerifyEmailRequestSchema,
|
||||
} from "./api-schemas";
|
||||
export {
|
||||
ISSUE_COMMENT_MAX_LENGTH,
|
||||
@@ -132,12 +148,17 @@ export {
|
||||
PROJECT_DESCRIPTION_MAX_LENGTH,
|
||||
PROJECT_NAME_MAX_LENGTH,
|
||||
PROJECT_SLUG_MAX_LENGTH,
|
||||
USER_EMAIL_MAX_LENGTH,
|
||||
USER_NAME_MAX_LENGTH,
|
||||
USER_USERNAME_MAX_LENGTH,
|
||||
} from "./constants";
|
||||
export type { ApiContract } from "./contract";
|
||||
export { apiContract } from "./contract";
|
||||
export type {
|
||||
EmailJobInsert,
|
||||
EmailJobRecord,
|
||||
EmailVerificationInsert,
|
||||
EmailVerificationRecord,
|
||||
IconStyle,
|
||||
IssueAssigneeInsert,
|
||||
IssueAssigneeRecord,
|
||||
@@ -153,6 +174,8 @@ export type {
|
||||
OrganisationMemberResponse as OrganisationMemberResponseRecord,
|
||||
OrganisationRecord,
|
||||
OrganisationResponse as OrganisationResponseRecord,
|
||||
PaymentInsert,
|
||||
PaymentRecord,
|
||||
ProjectInsert,
|
||||
ProjectRecord,
|
||||
ProjectResponse as ProjectResponseRecord,
|
||||
@@ -160,6 +183,8 @@ export type {
|
||||
SessionRecord,
|
||||
SprintInsert,
|
||||
SprintRecord,
|
||||
SubscriptionInsert,
|
||||
SubscriptionRecord as SubscriptionRecordType,
|
||||
TimedSessionInsert,
|
||||
TimedSessionRecord,
|
||||
TimerState,
|
||||
@@ -172,6 +197,12 @@ export {
|
||||
DEFAULT_SPRINT_COLOUR,
|
||||
DEFAULT_STATUS_COLOUR,
|
||||
DEFAULT_STATUS_COLOURS,
|
||||
EmailJob,
|
||||
EmailJobInsertSchema,
|
||||
EmailJobSelectSchema,
|
||||
EmailVerification,
|
||||
EmailVerificationInsertSchema,
|
||||
EmailVerificationSelectSchema,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueAssigneeInsertSchema,
|
||||
@@ -188,6 +219,9 @@ export {
|
||||
OrganisationMemberInsertSchema,
|
||||
OrganisationMemberSelectSchema,
|
||||
OrganisationSelectSchema,
|
||||
Payment,
|
||||
PaymentInsertSchema,
|
||||
PaymentSelectSchema,
|
||||
Project,
|
||||
ProjectInsertSchema,
|
||||
ProjectSelectSchema,
|
||||
@@ -197,6 +231,9 @@ export {
|
||||
Sprint,
|
||||
SprintInsertSchema,
|
||||
SprintSelectSchema,
|
||||
Subscription,
|
||||
SubscriptionInsertSchema,
|
||||
SubscriptionSelectSchema,
|
||||
TimedSession,
|
||||
TimedSessionInsertSchema,
|
||||
TimedSessionSelectSchema,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { 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 {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ORG_NAME_MAX_LENGTH,
|
||||
ORG_SLUG_MAX_LENGTH,
|
||||
PROJECT_NAME_MAX_LENGTH,
|
||||
USER_EMAIL_MAX_LENGTH,
|
||||
USER_NAME_MAX_LENGTH,
|
||||
USER_USERNAME_MAX_LENGTH,
|
||||
} from "./constants";
|
||||
@@ -56,9 +57,13 @@ export const User = pgTable("User", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: USER_NAME_MAX_LENGTH }).notNull(),
|
||||
username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(),
|
||||
email: varchar({ length: USER_EMAIL_MAX_LENGTH }).notNull().unique(),
|
||||
passwordHash: varchar({ length: 255 }).notNull(),
|
||||
avatarURL: varchar({ length: 512 }),
|
||||
iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(),
|
||||
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(),
|
||||
});
|
||||
@@ -192,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);
|
||||
|
||||
@@ -223,7 +227,6 @@ export const SessionInsertSchema = createInsertSchema(Session);
|
||||
export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
|
||||
export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
|
||||
|
||||
// Types
|
||||
export type UserRecord = z.infer<typeof UserSelectSchema>;
|
||||
export type UserInsert = z.infer<typeof UserInsertSchema>;
|
||||
|
||||
@@ -257,8 +260,6 @@ export type SessionInsert = z.infer<typeof SessionInsertSchema>;
|
||||
export type TimedSessionRecord = z.infer<typeof TimedSessionSelectSchema>;
|
||||
export type TimedSessionInsert = z.infer<typeof TimedSessionInsertSchema>;
|
||||
|
||||
// Responses
|
||||
|
||||
export type IssueResponse = {
|
||||
Issue: IssueRecord;
|
||||
Creator: UserRecord;
|
||||
@@ -295,3 +296,85 @@ export type TimerState = {
|
||||
timestamps: string[];
|
||||
endedAt: string | null;
|
||||
} | null;
|
||||
|
||||
export const Subscription = pgTable("Subscription", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer()
|
||||
.notNull()
|
||||
.references(() => User.id),
|
||||
stripeCustomerId: varchar({ length: 255 }),
|
||||
stripeSubscriptionId: varchar({ length: 255 }),
|
||||
stripeSubscriptionItemId: varchar({ length: 255 }),
|
||||
stripePriceId: varchar({ length: 255 }),
|
||||
status: varchar({ length: 32 }).notNull().default("incomplete"),
|
||||
currentPeriodStart: timestamp({ withTimezone: false }),
|
||||
currentPeriodEnd: timestamp({ withTimezone: false }),
|
||||
cancelAtPeriodEnd: boolean().notNull().default(false),
|
||||
trialEnd: timestamp({ withTimezone: false }),
|
||||
quantity: integer().notNull().default(1),
|
||||
createdAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||
});
|
||||
|
||||
export const Payment = pgTable("Payment", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
subscriptionId: integer()
|
||||
.notNull()
|
||||
.references(() => Subscription.id),
|
||||
stripePaymentIntentId: varchar({ length: 255 }),
|
||||
amount: integer().notNull(),
|
||||
currency: varchar({ length: 3 }).notNull().default("gbp"),
|
||||
status: varchar({ length: 32 }).notNull(),
|
||||
createdAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||
});
|
||||
|
||||
export const SubscriptionSelectSchema = createSelectSchema(Subscription);
|
||||
export const SubscriptionInsertSchema = createInsertSchema(Subscription);
|
||||
|
||||
export const PaymentSelectSchema = createSelectSchema(Payment);
|
||||
export const PaymentInsertSchema = createInsertSchema(Payment);
|
||||
|
||||
export type SubscriptionRecord = z.infer<typeof SubscriptionSelectSchema>;
|
||||
export type SubscriptionInsert = z.infer<typeof SubscriptionInsertSchema>;
|
||||
|
||||
export type PaymentRecord = z.infer<typeof PaymentSelectSchema>;
|
||||
export type PaymentInsert = z.infer<typeof PaymentInsertSchema>;
|
||||
|
||||
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<typeof EmailVerificationSelectSchema>;
|
||||
export type EmailVerificationInsert = z.infer<typeof EmailVerificationInsertSchema>;
|
||||
|
||||
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<Record<string, unknown>>(),
|
||||
createdAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||
});
|
||||
|
||||
export const EmailJobSelectSchema = createSelectSchema(EmailJob);
|
||||
export const EmailJobInsertSchema = createInsertSchema(EmailJob);
|
||||
|
||||
export type EmailJobRecord = z.infer<typeof EmailJobSelectSchema>;
|
||||
export type EmailJobInsert = z.infer<typeof EmailJobInsertSchema>;
|
||||
|
||||
Reference in New Issue
Block a user