diff --git a/bun.lock b/bun.lock index c15f919..e26f82d 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 068b4b9..c1ab18f 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/packages/backend/drizzle/0026_stale_shocker.sql b/packages/backend/drizzle/0026_stale_shocker.sql new file mode 100644 index 0000000..f5d468c --- /dev/null +++ b/packages/backend/drizzle/0026_stale_shocker.sql @@ -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; \ No newline at end of file diff --git a/packages/backend/drizzle/0027_volatile_otto_octavius.sql b/packages/backend/drizzle/0027_volatile_otto_octavius.sql new file mode 100644 index 0000000..aaa2d87 --- /dev/null +++ b/packages/backend/drizzle/0027_volatile_otto_octavius.sql @@ -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"); \ No newline at end of file diff --git a/packages/backend/drizzle/0028_quick_supernaut.sql b/packages/backend/drizzle/0028_quick_supernaut.sql new file mode 100644 index 0000000..dfd5aba --- /dev/null +++ b/packages/backend/drizzle/0028_quick_supernaut.sql @@ -0,0 +1,28 @@ +CREATE TABLE "EmailJob" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailJob_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "userId" integer NOT NULL, + "type" varchar(64) NOT NULL, + "scheduledFor" timestamp NOT NULL, + "sentAt" timestamp, + "failedAt" timestamp, + "errorMessage" text, + "metadata" json, + "createdAt" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "EmailVerification" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailVerification_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "userId" integer NOT NULL, + "code" varchar(6) NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 5 NOT NULL, + "expiresAt" timestamp NOT NULL, + "verifiedAt" timestamp, + "createdAt" timestamp DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "User" ALTER COLUMN "email" SET DATA TYPE varchar(256);--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "emailVerified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" timestamp;--> statement-breakpoint +ALTER TABLE "EmailJob" ADD CONSTRAINT "EmailJob_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "EmailVerification" ADD CONSTRAINT "EmailVerification_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0026_snapshot.json b/packages/backend/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000..4a2a899 --- /dev/null +++ b/packages/backend/drizzle/meta/0026_snapshot.json @@ -0,0 +1,1146 @@ +{ + "id": "9104baeb-85d7-4fdb-87cc-9d5ac6b85ec8", + "prevId": "c4f3042a-375d-4d17-b836-3d7816b26519", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0027_snapshot.json b/packages/backend/drizzle/meta/0027_snapshot.json new file mode 100644 index 0000000..ba821f3 --- /dev/null +++ b/packages/backend/drizzle/meta/0027_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "b826ec09-e4ac-49b1-9975-b36f5be69b0b", + "prevId": "9104baeb-85d7-4fdb-87cc-9d5ac6b85ec8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0028_snapshot.json b/packages/backend/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..68abde6 --- /dev/null +++ b/packages/backend/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1354 @@ +{ + "id": "4e8f597a-39c9-47c6-9eb4-a085a88bc1b5", + "prevId": "b826ec09-e4ac-49b1-9975-b36f5be69b0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.EmailJob": { + "name": "EmailJob", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "EmailJob_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAt": { + "name": "failedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "EmailJob_userId_User_id_fk": { + "name": "EmailJob_userId_User_id_fk", + "tableFrom": "EmailJob", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmailVerification": { + "name": "EmailVerification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "EmailVerification_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "verifiedAt": { + "name": "verifiedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "EmailVerification_userId_User_id_fk": { + "name": "EmailVerification_userId_User_id_fk", + "tableFrom": "EmailVerification", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailVerifiedAt": { + "name": "emailVerifiedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index acb30b1..a2bcbb9 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index a0c62e5..7a072aa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -20,6 +20,8 @@ "@types/bun": "latest", "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.15.6", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.8", "tsx": "^4.21.0" }, @@ -27,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" } } diff --git a/packages/backend/scripts/db-seed.ts b/packages/backend/scripts/db-seed.ts index 56ad15e..ccb0ee2 100644 --- a/packages/backend/scripts/db-seed.ts +++ b/packages/backend/scripts/db-seed.ts @@ -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() { diff --git a/packages/backend/src/db/queries/email-verification.ts b/packages/backend/src/db/queries/email-verification.ts new file mode 100644 index 0000000..21528da --- /dev/null +++ b/packages/backend/src/db/queries/email-verification.ts @@ -0,0 +1,121 @@ +import { EmailVerification, type EmailVerificationRecord, User } from "@sprint/shared"; +import { eq, lt, sql } from "drizzle-orm"; +import { db } from "../client"; + +const CODE_EXPIRY_MINUTES = 15; +const MAX_ATTEMPTS = 5; + +export function generateVerificationCode(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + + // 6 digit + const code = ((bytes[0] ?? 0) * 256 * 256 + (bytes[1] ?? 0) * 256 + (bytes[2] ?? 0)) % 1000000; + return code.toString().padStart(6, "0"); +} + +export async function createVerificationCode(userId: number): Promise { + const code = generateVerificationCode(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000); + + // delete existing codes for the user + await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId)); + + const [verification] = await db + .insert(EmailVerification) + .values({ + userId, + code, + expiresAt, + attempts: 0, + maxAttempts: MAX_ATTEMPTS, + }) + .returning(); + + if (!verification) { + throw new Error("Failed to create verification code"); + } + + return verification; +} + +export async function getVerificationByUserId(userId: number): Promise { + const [verification] = await db + .select() + .from(EmailVerification) + .where(eq(EmailVerification.userId, userId)); + return verification; +} + +export async function incrementAttempts(id: number): Promise { + await db + .update(EmailVerification) + .set({ + attempts: sql`CASE WHEN ${EmailVerification.attempts} IS NULL THEN 1 ELSE ${EmailVerification.attempts} + 1 END`, + }) + .where(eq(EmailVerification.id, id)); +} + +export async function markAsVerified(id: number): Promise { + await db.update(EmailVerification).set({ verifiedAt: new Date() }).where(eq(EmailVerification.id, id)); +} + +export async function deleteVerification(id: number): Promise { + await db.delete(EmailVerification).where(eq(EmailVerification.id, id)); +} + +export async function deleteUserVerifications(userId: number): Promise { + await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId)); +} + +export async function cleanupExpiredVerifications(): Promise { + const result = await db.delete(EmailVerification).where(lt(EmailVerification.expiresAt, new Date())); + return result.rowCount ?? 0; +} + +export async function verifyCode( + userId: number, + code: string, +): Promise<{ success: boolean; error?: string }> { + const verification = await getVerificationByUserId(userId); + + if (!verification) { + return { success: false, error: "No verification code found" }; + } + + if (verification.verifiedAt) { + return { success: false, error: "Email already verified" }; + } + + if (new Date() > verification.expiresAt) { + await deleteVerification(verification.id); + return { success: false, error: "Verification code expired" }; + } + + if (verification.attempts >= verification.maxAttempts) { + await deleteVerification(verification.id); + return { success: false, error: "Too many attempts. Please request a new code." }; + } + + if (verification.code !== code) { + await db + .update(EmailVerification) + .set({ attempts: verification.attempts + 1 }) + .where(eq(EmailVerification.id, verification.id)); + + const remainingAttempts = verification.maxAttempts - (verification.attempts + 1); + return { + success: false, + error: `Invalid code. ${remainingAttempts} attempts remaining.`, + }; + } + + await db + .update(User) + .set({ emailVerified: true, emailVerifiedAt: new Date() }) + .where(eq(User.id, userId)); + + await deleteVerification(verification.id); + + return { success: true }; +} diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index 99988cb..c9f3a96 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -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; diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index 8466685..41ccadd 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -259,6 +259,25 @@ export async function getIssueAssigneeCount(issueId: number): Promise { return result?.count ?? 0; } +export async function getOrganisationIssueCount(organisationId: number): Promise { + 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`COUNT(*)` }) + .from(Issue) + .where(inArray(Issue.projectId, projectIds)); + + return result?.count ?? 0; +} + export async function isIssueAssignee(issueId: number, userId: number): Promise { const [assignee] = await db .select({ id: IssueAssignee.id }) diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index c56e5be..2882e37 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -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 { + const [result] = await db + .select({ count: sql`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; +} diff --git a/packages/backend/src/db/queries/projects.ts b/packages/backend/src/db/queries/projects.ts index 8e53eab..ad1f4b4 100644 --- a/packages/backend/src/db/queries/projects.ts +++ b/packages/backend/src/db/queries/projects.ts @@ -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 { + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(Project) + .where(eq(Project.organisationId, organisationId)); + return result?.count ?? 0; +} diff --git a/packages/backend/src/db/queries/sprints.ts b/packages/backend/src/db/queries/sprints.ts index b539cc5..b6ef4a8 100644 --- a/packages/backend/src/db/queries/sprints.ts +++ b/packages/backend/src/db/queries/sprints.ts @@ -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`count(*)::int` }) + .from(Sprint) + .where(eq(Sprint.projectId, projectId)); + return result[0]?.count ?? 0; +} diff --git a/packages/backend/src/db/queries/subscriptions.ts b/packages/backend/src/db/queries/subscriptions.ts new file mode 100644 index 0000000..a6732e3 --- /dev/null +++ b/packages/backend/src/db/queries/subscriptions.ts @@ -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)); +} diff --git a/packages/backend/src/db/queries/timed-sessions.ts b/packages/backend/src/db/queries/timed-sessions.ts index d0a0ee4..b4fc924 100644 --- a/packages/backend/src/db/queries/timed-sessions.ts +++ b/packages/backend/src/db/queries/timed-sessions.ts @@ -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 diff --git a/packages/backend/src/db/queries/users.ts b/packages/backend/src/db/queries/users.ts index bd034c6..ca9a567 100644 --- a/packages/backend/src/db/queries/users.ts +++ b/packages/backend/src/db/queries/users.ts @@ -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 { 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; +} diff --git a/packages/backend/src/emails/index.ts b/packages/backend/src/emails/index.ts new file mode 100644 index 0000000..7e11cd6 --- /dev/null +++ b/packages/backend/src/emails/index.ts @@ -0,0 +1 @@ +export { VerificationCode } from "./templates/VerificationCode"; diff --git a/packages/backend/src/emails/templates/VerificationCode.tsx b/packages/backend/src/emails/templates/VerificationCode.tsx new file mode 100644 index 0000000..0a1e2eb --- /dev/null +++ b/packages/backend/src/emails/templates/VerificationCode.tsx @@ -0,0 +1,3 @@ +export function VerificationCode({ code }: { code: string }) { + return Your sprint verification code is: {code}; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 843ffec..a906004 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -41,6 +41,8 @@ const main = async () => { "/auth/login": withGlobal(routes.authLogin), "/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))), "/auth/me": withGlobalAuthed(withAuth(routes.authMe)), + "/auth/verify-email": withGlobalAuthed(withAuth(withCSRF(routes.authVerifyEmail))), + "/auth/resend-verification": withGlobalAuthed(withAuth(withCSRF(routes.authResendVerification))), "/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)), "/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))), @@ -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), }, }); diff --git a/packages/backend/src/lib/email/service.ts b/packages/backend/src/lib/email/service.ts new file mode 100644 index 0000000..1a57b61 --- /dev/null +++ b/packages/backend/src/lib/email/service.ts @@ -0,0 +1,54 @@ +import { render } from "@react-email/render"; +import type React from "react"; +import { Resend } from "resend"; + +const resend = new Resend(process.env.RESEND_API_KEY); + +const FROM_EMAIL = process.env.EMAIL_FROM || "Sprint "; + +export interface SendEmailOptions { + to: string; + subject: string; + template: React.ReactElement; + from?: string; +} + +export async function sendEmail({ to, subject, template, from }: SendEmailOptions) { + const html = await render(template); + + const { data, error } = await resend.emails.send({ + from: from || FROM_EMAIL, + to, + subject, + html, + }); + + if (error) { + console.error("Failed to send email:", error); + throw new Error(`Email send failed: ${error.message}`); + } + + return data; +} + +export async function sendEmailWithRetry( + options: SendEmailOptions, + maxRetries = 3, +): Promise> { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await sendEmail(options); + } catch (error) { + lastError = error as Error; + console.warn(`Email send attempt ${attempt} failed:`, error); + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1))); + } + } + } + + throw lastError || new Error("Email send failed after all retries"); +} diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts new file mode 100644 index 0000000..6f444a4 --- /dev/null +++ b/packages/backend/src/lib/seats.ts @@ -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 }); +} diff --git a/packages/backend/src/routes/auth/login.ts b/packages/backend/src/routes/auth/login.ts index 4bda879..84d5271 100644 --- a/packages/backend/src/routes/auth/login.ts +++ b/packages/backend/src/routes/auth/login.ts @@ -39,6 +39,7 @@ export default async function login(req: BunRequest) { username: user.username, avatarURL: user.avatarURL, iconPreference: user.iconPreference, + emailVerified: user.emailVerified, }, csrfToken: session.csrfToken, }), diff --git a/packages/backend/src/routes/auth/me.ts b/packages/backend/src/routes/auth/me.ts index b015e49..3007479 100644 --- a/packages/backend/src/routes/auth/me.ts +++ b/packages/backend/src/routes/auth/me.ts @@ -13,5 +13,6 @@ export default async function me(req: AuthedRequest) { return Response.json({ user: safeUser as Omit, csrfToken: req.csrfToken, + emailVerified: user.emailVerified, }); } diff --git a/packages/backend/src/routes/auth/register.ts b/packages/backend/src/routes/auth/register.ts index 6ca2ab0..3bcaa3e 100644 --- a/packages/backend/src/routes/auth/register.ts +++ b/packages/backend/src/routes/auth/register.ts @@ -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, }), diff --git a/packages/backend/src/routes/auth/resend-verification.ts b/packages/backend/src/routes/auth/resend-verification.ts new file mode 100644 index 0000000..5f69fdf --- /dev/null +++ b/packages/backend/src/routes/auth/resend-verification.ts @@ -0,0 +1,69 @@ +import type { BunRequest } from "bun"; +import type { AuthedRequest } from "../../auth/middleware"; +import { createVerificationCode } from "../../db/queries"; +import { getUserById } from "../../db/queries/users"; +import { VerificationCode } from "../../emails"; +import { sendEmailWithRetry } from "../../lib/email/service"; +import { errorResponse } from "../../validation"; + +const resendAttempts = new Map(); + +const MAX_RESENDS_PER_HOUR = 3; +const HOUR_IN_MS = 60 * 60 * 1000; + +function canResend(userId: number): boolean { + const now = Date.now(); + const attempts = resendAttempts.get(userId) || []; + + const recentAttempts = attempts.filter((time) => now - time < HOUR_IN_MS); + + if (recentAttempts.length >= MAX_RESENDS_PER_HOUR) { + return false; + } + + recentAttempts.push(now); + resendAttempts.set(userId, recentAttempts); + return true; +} + +export default async function resendVerification(req: BunRequest | AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + const authedReq = req as AuthedRequest; + if (!authedReq.userId) { + return errorResponse("unauthorized", "UNAUTHORIZED", 401); + } + + if (!canResend(authedReq.userId)) { + return errorResponse("too many resend attempts. please try again later", "RATE_LIMITED", 429); + } + + const user = await getUserById(authedReq.userId); + if (!user) { + return errorResponse("user not found", "USER_NOT_FOUND", 404); + } + + if (user.emailVerified) { + return errorResponse("email already verified", "ALREADY_VERIFIED", 400); + } + + const verification = await createVerificationCode(user.id); + + try { + await sendEmailWithRetry({ + to: user.email, + subject: "Verify your Sprint account", + template: VerificationCode({ code: verification.code }), + }); + } catch (error) { + console.error("Failed to send verification email:", error); + return errorResponse("failed to send verification email", "EMAIL_SEND_FAILED", 500); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/backend/src/routes/auth/verify-email.ts b/packages/backend/src/routes/auth/verify-email.ts new file mode 100644 index 0000000..1215d0d --- /dev/null +++ b/packages/backend/src/routes/auth/verify-email.ts @@ -0,0 +1,32 @@ +import { VerifyEmailRequestSchema } from "@sprint/shared"; +import type { BunRequest } from "bun"; +import type { AuthedRequest } from "../../auth/middleware"; +import { verifyCode } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; + +export default async function verifyEmail(req: BunRequest | AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + const authedReq = req as AuthedRequest; + if (!authedReq.userId) { + return errorResponse("unauthorized", "UNAUTHORIZED", 401); + } + + const parsed = await parseJsonBody(req, VerifyEmailRequestSchema); + if ("error" in parsed) return parsed.error; + + const { code } = parsed.data; + + const result = await verifyCode(authedReq.userId, code); + + if (!result.success) { + return errorResponse(result.error || "verification failed", "VERIFICATION_FAILED", 400); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 2176fdb..1d34d50 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -2,6 +2,8 @@ import authLogin from "./auth/login"; import authLogout from "./auth/logout"; import authMe from "./auth/me"; import authRegister from "./auth/register"; +import authResendVerification from "./auth/resend-verification"; +import authVerifyEmail from "./auth/verify-email"; import issueById from "./issue/by-id"; import issueCreate from "./issue/create"; import issueDelete from "./issue/delete"; @@ -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, }; diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index 34cfb32..274106c 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -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, diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index aec6224..1b06abc 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -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); } diff --git a/packages/backend/src/routes/organisation/create.ts b/packages/backend/src/routes/organisation/create.ts index f9258ea..7fe40d6 100644 --- a/packages/backend/src/routes/organisation/create.ts +++ b/packages/backend/src/routes/organisation/create.ts @@ -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); diff --git a/packages/backend/src/routes/organisation/member-time-tracking.ts b/packages/backend/src/routes/organisation/member-time-tracking.ts new file mode 100644 index 0000000..1bdbc5e --- /dev/null +++ b/packages/backend/src/routes/organisation/member-time-tracking.ts @@ -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); +} diff --git a/packages/backend/src/routes/organisation/remove-member.ts b/packages/backend/src/routes/organisation/remove-member.ts index 2497768..5d588b8 100644 --- a/packages/backend/src/routes/organisation/remove-member.ts +++ b/packages/backend/src/routes/organisation/remove-member.ts @@ -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 }); } diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index 6fae359..7745dfa 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -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", diff --git a/packages/backend/src/routes/project/create.ts b/packages/backend/src/routes/project/create.ts index 5fcfd25..4fead4a 100644 --- a/packages/backend/src/routes/project/create.ts +++ b/packages/backend/src/routes/project/create.ts @@ -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); } diff --git a/packages/backend/src/routes/sprint/create.ts b/packages/backend/src/routes/sprint/create.ts index 9d535e8..3a6291f 100644 --- a/packages/backend/src/routes/sprint/create.ts +++ b/packages/backend/src/routes/sprint/create.ts @@ -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); diff --git a/packages/backend/src/routes/subscription/cancel.ts b/packages/backend/src/routes/subscription/cancel.ts new file mode 100644 index 0000000..74806f0 --- /dev/null +++ b/packages/backend/src/routes/subscription/cancel.ts @@ -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))); diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts new file mode 100644 index 0000000..61f1aa5 --- /dev/null +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -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))); diff --git a/packages/backend/src/routes/subscription/create-portal-session.ts b/packages/backend/src/routes/subscription/create-portal-session.ts new file mode 100644 index 0000000..09beeb9 --- /dev/null +++ b/packages/backend/src/routes/subscription/create-portal-session.ts @@ -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))); diff --git a/packages/backend/src/routes/subscription/get.ts b/packages/backend/src/routes/subscription/get.ts new file mode 100644 index 0000000..59abaa5 --- /dev/null +++ b/packages/backend/src/routes/subscription/get.ts @@ -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)); diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts new file mode 100644 index 0000000..92d4b8e --- /dev/null +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -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 }); + } +} diff --git a/packages/backend/src/routes/user/update.ts b/packages/backend/src/routes/user/update.ts index 7ef2af5..c1c3ce4 100644 --- a/packages/backend/src/routes/user/update.ts +++ b/packages/backend/src/routes/user/update.ts @@ -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); diff --git a/packages/backend/src/routes/user/upload-avatar.ts b/packages/backend/src/routes/user/upload-avatar.ts index 21792d1..bdfe994 100644 --- a/packages/backend/src/routes/user/upload-avatar.ts +++ b/packages/backend/src/routes/user/upload-avatar.ts @@ -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 { + 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" }) diff --git a/packages/backend/src/stripe/client.ts b/packages/backend/src/stripe/client.ts new file mode 100644 index 0000000..1700678 --- /dev/null +++ b/packages/backend/src/stripe/client.ts @@ -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; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 711e00c..9c2b92e 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" } } diff --git a/packages/frontend/src/components/account.tsx b/packages/frontend/src/components/account.tsx index 5ebe4f1..a88e3cd 100644 --- a/packages/frontend/src/components/account.tsx +++ b/packages/frontend/src/components/account.tsx @@ -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 }) {
- - setIconPreference(v as IconStyle)} + disabled={currentUser.plan !== "pro"} + > + @@ -156,12 +180,33 @@ function Account({ trigger }: { trigger?: ReactNode }) { + {currentUser.plan !== "pro" && ( + + + Upgrade to Pro + {" "} + to customize icon style + + )}
{error !== "" && } -
+ {/* Show subscription management link */} +
+ {currentUser.plan === "pro" ? ( + + ) : ( + + )} +
+ +
diff --git a/packages/frontend/src/components/free-tier-limit.tsx b/packages/frontend/src/components/free-tier-limit.tsx new file mode 100644 index 0000000..933d3ae --- /dev/null +++ b/packages/frontend/src/components/free-tier-limit.tsx @@ -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 ( +
+
+ + {current} / {limit} {itemName} + {current !== 1 ? "s" : ""} + + {isAtLimit && Limit reached} + {isNearLimit && Almost at limit} +
+
+
+
+ {isAtLimit && showUpgrade && ( +
+ + Upgrade to Pro for unlimited {itemName}s + +
+ )} +
+ ); +} + +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 ( +
+ + + {current}/{limit} {itemName} + {current !== 1 ? "s" : ""} + +
+ ); +} diff --git a/packages/frontend/src/components/issue-form.tsx b/packages/frontend/src/components/issue-form.tsx index f438892..337bcb1 100644 --- a/packages/frontend/src/components/issue-form.tsx +++ b/packages/frontend/src/components/issue-form.tsx @@ -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 }) { {trigger || ( - )} @@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { Create Issue + {!isPro && selectedProject && ( +
+ +
+ )} +
{(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"} diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index 9093341..70bf9cd 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -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(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} /> + setEmail(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + spellcheck={false} + maxLength={USER_EMAIL_MAX_LENGTH} + /> )} { return localStorage.getItem("hide-under-construction") !== "true"; }); useEffect(() => { - if (open && !isLoading && user && !hasRedirected) { + if (open && !isLoading && user && emailVerified && !hasRedirected) { setHasRedirected(true); const next = searchParams.get("next") || "/issues"; navigate(next, { replace: true }); onSuccess?.(); onOpenChange(false); } - }, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]); + }, [open, user, isLoading, emailVerified, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]); useEffect(() => { if (!open) { diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index f462eee..2253cfb 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -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(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 && } + {!isPro && ( +
+ +
+ )} + + } diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index f0e6cd2..0133903 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -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(() => { + // 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 = { 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(); + 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 = { 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 }) {

No description

)}
+ + {/* Free tier limits section */} + {!isPro && ( +
+
+

Plan Limits

+ +
+
+ + + + +
+
+ )} + {isAdmin && (
+ + + date && setFromDate(date)} + autoFocus + /> + + + + + + + + downloadTimeTrackingData("csv")}> + Download CSV + + downloadTimeTrackingData("json")}> + Download JSON + + + + + )} +
+ )} +
- {members.map((member) => ( + {membersWithTimeTracking.map((member) => (
+ {isAdmin && isPro && ( + + {formatDuration(member.totalTimeMs)} + + )} {isAdmin && member.OrganisationMember.role !== "owner" && member.User.id !== user.id && ( @@ -803,25 +1032,46 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { ))}
{isAdmin && ( - m.User.username)} - onSuccess={(user) => { - toast.success( - `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, - { - dismissible: false, - }, - ); + <> + {!isPro && ( +
+ = FREE_TIER_LIMITS.membersPerOrganisation} + /> +
+ )} + m.User.username)} + onSuccess={(user) => { + toast.success( + `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, + { + dismissible: false, + }, + ); - void invalidateMembers(); - }} - trigger={ - - } - /> + void invalidateMembers(); + }} + trigger={ + + } + /> + )}
@@ -1272,6 +1522,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {

Features

+ {!isPro && ( +
+ Feature toggling is only available on Pro.{" "} + + Upgrade to customize features. + +
+ )}
{Object.keys(DEFAULT_FEATURES).map((feature) => (
@@ -1293,9 +1551,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { ); await invalidateOrganisations(); }} + disabled={!isPro} color={"#ff0000"} /> - {unCamelCase(feature)} + + {unCamelCase(feature)} +
))}
diff --git a/packages/frontend/src/components/pricing-card.tsx b/packages/frontend/src/components/pricing-card.tsx new file mode 100644 index 0000000..6eed351 --- /dev/null +++ b/packages/frontend/src/components/pricing-card.tsx @@ -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 ( +
+ {tier.highlighted && ( +
+ {tier.tagline} +
+ )} + +
+

{tier.name}

+
+ + {billingPeriod === "annual" ? tier.priceAnnual : tier.price} + + + {billingPeriod === "annual" ? tier.periodAnnual : tier.period} + +
+

{tier.description}

+
+ +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + +
+ ); +} + +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, + }, +]; diff --git a/packages/frontend/src/components/project-select.tsx b/packages/frontend/src/components/project-select.tsx index 06b6ecb..618dde7 100644 --- a/packages/frontend/src/components/project-select.tsx +++ b/packages/frontend/src/components/project-select.tsx @@ -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(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 && } + + {!isPro && selectedOrganisationId && ( +
+ +
+ )} + + } diff --git a/packages/frontend/src/components/server-configuration.tsx b/packages/frontend/src/components/server-configuration.tsx index 779730b..4e0a085 100644 --- a/packages/frontend/src/components/server-configuration.tsx +++ b/packages/frontend/src/components/server-configuration.tsx @@ -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("/")) { diff --git a/packages/frontend/src/components/session-provider.tsx b/packages/frontend/src/components/session-provider.tsx index 4eef767..b3d51d2 100644 --- a/packages/frontend/src/components/session-provider.tsx +++ b/packages/frontend/src/components/session-provider.tsx @@ -3,12 +3,16 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f import Loading from "@/components/loading"; import { LoginModal } from "@/components/login-modal"; +import { VerificationModal } from "@/components/verification-modal"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; interface SessionContextValue { user: UserResponse | null; setUser: (user: UserResponse) => void; isLoading: boolean; + emailVerified: boolean; + setEmailVerified: (verified: boolean) => void; + refreshUser: () => Promise; } const SessionContext = createContext(null); @@ -39,6 +43,7 @@ export function useAuthenticatedSession(): { user: UserResponse; setUser: (user: export function SessionProvider({ children }: { children: React.ReactNode }) { const [user, setUserState] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [emailVerified, setEmailVerified] = useState(true); const fetched = useRef(false); const setUser = useCallback((user: UserResponse) => { @@ -46,6 +51,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { localStorage.setItem("user", JSON.stringify(user)); }, []); + const refreshUser = useCallback(async () => { + const res = await fetch(`${getServerURL()}/auth/me`, { + credentials: "include", + }); + if (!res.ok) { + throw new Error(`auth check failed: ${res.status}`); + } + const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean }; + setUser(data.user); + setCsrfToken(data.csrfToken); + setEmailVerified(data.emailVerified); + }, [setUser]); + useEffect(() => { if (fetched.current) return; fetched.current = true; @@ -57,9 +75,10 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { if (!res.ok) { throw new Error(`auth check failed: ${res.status}`); } - const data = (await res.json()) as { user: UserResponse; csrfToken: string }; + const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean }; setUser(data.user); setCsrfToken(data.csrfToken); + setEmailVerified(data.emailVerified); }) .catch(() => { setUserState(null); @@ -70,11 +89,17 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }); }, [setUser]); - return {children}; + return ( + + {children} + + ); } export function RequireAuth({ children }: { children: React.ReactNode }) { - const { user, isLoading } = useSession(); + const { user, isLoading, emailVerified } = useSession(); const [loginModalOpen, setLoginModalOpen] = useState(false); useEffect(() => { @@ -93,5 +118,9 @@ export function RequireAuth({ children }: { children: React.ReactNode }) { return ; } + if (user && !emailVerified) { + return {}} />; + } + return <>{children}; } diff --git a/packages/frontend/src/components/sprint-form.tsx b/packages/frontend/src/components/sprint-form.tsx index a4b418d..d550879 100644 --- a/packages/frontend/src/components/sprint-form.tsx +++ b/packages/frontend/src/components/sprint-form.tsx @@ -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({ )}
+ {!isEdit && ( + = FREE_TIER_SPRINT_LIMIT} + /> + )} +
+ {user.plan !== "pro" && ( + + )} diff --git a/packages/frontend/src/components/ui/alert-dialog.tsx b/packages/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d7281f9 --- /dev/null +++ b/packages/frontend/src/components/ui/alert-dialog.tsx @@ -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) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +type AlertDialogActionProps = React.ComponentProps & + Omit, "asChild">; + +function AlertDialogAction({ className, ...props }: AlertDialogActionProps) { + return ( + + + + +
+ + + + ); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index c548710..7c0564f 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/query/hooks/organisations.ts b/packages/frontend/src/lib/query/hooks/organisations.ts index 41188f0..527634c 100644 --- a/packages/frontend/src/lib/query/hooks/organisations.ts +++ b/packages/frontend/src/lib/query/hooks/organisations.ts @@ -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({ queryKey: queryKeys.organisations.byUser(), @@ -39,6 +53,23 @@ export function useOrganisationMembers(organisationId?: number | null) { }); } +export function useOrganisationMemberTimeTracking(organisationId?: number | null, fromDate?: Date) { + return useQuery({ + 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(); diff --git a/packages/frontend/src/lib/query/hooks/subscriptions.ts b/packages/frontend/src/lib/query/hooks/subscriptions.ts new file mode 100644 index 0000000..c43a510 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/subscriptions.ts @@ -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({ + 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({ + 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({ + 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({ + 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() }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/verification.ts b/packages/frontend/src/lib/query/hooks/verification.ts new file mode 100644 index 0000000..c0ebdfe --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/verification.ts @@ -0,0 +1,22 @@ +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/server"; + +export function useVerifyEmail() { + return useMutation({ + mutationKey: ["verification", "verify"], + mutationFn: async ({ code }) => { + const { error } = await apiClient.authVerifyEmail({ body: { code } }); + if (error) throw new Error(error); + }, + }); +} + +export function useResendVerification() { + return useMutation({ + mutationKey: ["verification", "resend"], + mutationFn: async () => { + const { error } = await apiClient.authResendVerification({ body: {} }); + if (error) throw new Error(error); + }, + }); +} diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index 67c3260..19f3782 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -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, + }, }; diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index 1e7e8c0..58ebdb9 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -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"; +}; diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 9e9b102..48e3cac 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -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 */} } /> } /> + } /> {/* authed routes */} + + + + } + /> } /> + - diff --git a/packages/frontend/src/pages/BoringStuff.tsx b/packages/frontend/src/pages/BoringStuff.tsx new file mode 100644 index 0000000..662f85f --- /dev/null +++ b/packages/frontend/src/pages/BoringStuff.tsx @@ -0,0 +1,122 @@ +import { Link } from "react-router-dom"; +import ThemeToggle from "@/components/theme-toggle"; +export default function BoringStuff() { + return ( +
+
+
+
+ Sprint + Sprint +
+ +
+
+ +
+
+
+

The Boring Stuff

+

Let's keep it short.

+
+ +
+

Privacy Policy

+
+

+ What we store: We store your email, name, and any + data you create (issues, projects, time tracking). +

+

+ How we use it: Only your email is used for + subscription alerts and newsletters (you can unsubscribe). +

+

+ Where it's stored: Data is stored on secure + servers. +

+

+ {/* Your rights: You can export or delete your data + anytime. Just email us at privacy@sprintpm.org. */} +

+

+ Cookies: We use essential cookies for + authentication. +

+
+
+ +
+

Terms of Service

+
+

+ The basics: Sprint is a project management tool. + Use it to organise work, track issues, and manage time. Don't use it for illegal stuff. +

+

+ Your account: You're responsible for keeping your + login details secure. Don't share your account. +

+

+ Payments: Pro plans are billed monthly or + annually. Cancel anytime from your account settings. No refunds for partial months. +

+

+ Service availability: We aim for 99.9% uptime but + can't guarantee it. We may occasionally need downtime for maintenance. +

+

+ Termination: We may suspend accounts that violate + these terms. +

+

+ Changes: We'll notify you of significant changes + to these terms via email. +

+
+
+ +
+

Questions?

+

+ Email us at{" "} + + support@sprintpm.org + {" "} + - we'll get back to you within 24 hours. +

+
+ +
+

Last updated: January 2025

+
+
+
+ + +
+ ); +} diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 0770c04..bab3e2e 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -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() { ) : ( <>
-

No credit card required · Full access for 14 days

+

Free forever · Upgrade when you need more

{/* problem section */} @@ -323,57 +269,12 @@ export default function Landing() {
{pricingTiers.map((tier) => ( -
- {tier.highlighted && ( -
- {tier.tagline} -
- )} - -
-

{tier.name}

-
- - {billingPeriod === "annual" ? tier.priceAnnual : tier.price} - - - {billingPeriod === "annual" ? tier.periodAnnual : tier.period} - -
-

{tier.description}

-
- -
    - {tier.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - -
+ tier={tier} + billingPeriod={billingPeriod} + onCtaClick={() => setLoginModalOpen(true)} + /> ))}
@@ -391,8 +292,8 @@ export default function Landing() { className="size-8" color="var(--personality)" /> -

No Card Required

-

Start your trial instantly

+

Free Starter Plan

+

Get started instantly

@@ -456,12 +357,12 @@ export default function Landing() { ) : ( )}

- No credit card required · 14-day free trial · Cancel anytime + Free forever · Upgrade when you need more · Cancel anytime

@@ -469,21 +370,29 @@ export default function Landing() { -