From 4b4f539b468c611d99400bdb54e1b96f80002975 Mon Sep 17 00:00:00 2001 From: rxghavc Date: Sat, 28 Jun 2025 21:39:10 +0100 Subject: [PATCH 01/54] started functionality for groups/groupid --- SETUP.md | 167 +++++ package-lock.json | 432 +++++++++++- package.json | 10 + src/app/api/auth/forgot-password/route.ts | 57 ++ src/app/api/auth/login/route.ts | 55 +- src/app/api/auth/logout/route.ts | 17 +- src/app/api/auth/me/route.ts | 55 +- src/app/api/auth/reset-password/route.ts | 56 ++ src/app/api/auth/signup/route.ts | 69 +- src/app/api/bets/[betId]/outcome/route.ts | 119 +++- src/app/api/bets/[betId]/payouts/route.ts | 76 +- src/app/api/bets/[betId]/route.ts | 157 ++++- src/app/api/bets/[betId]/vote/route.ts | 133 +++- src/app/api/bets/route.ts | 144 ++++ src/app/api/groups/[groupId]/bets/route.ts | 73 +- .../[groupId]/members/[userId]/route.ts | 92 ++- src/app/api/groups/[groupId]/members/route.ts | 43 +- src/app/api/groups/[groupId]/route.ts | 57 +- src/app/api/groups/join/route.ts | 62 +- src/app/api/groups/route.ts | 119 +++- src/app/api/users/[userId]/bets/route.ts | 98 ++- src/app/api/users/[userId]/route.ts | 74 +- src/app/forgot-password/page.tsx | 124 ++++ src/app/groups/[groupId]/page.tsx | 655 +++++++++++++++++- src/app/groups/page.tsx | 450 +++++++++++- src/app/layout.tsx | 27 +- src/app/login/page.tsx | 139 +++- src/app/reset-password/page.tsx | 243 +++++++ src/app/signup/page.tsx | 235 ++++++- src/components/app-sidebar.tsx | 0 src/components/site-header.tsx | 0 src/components/theme-toggle.tsx | 0 src/components/ui/BetCard.tsx | 385 ++++++++++ src/components/ui/BetForm.tsx | 191 +++++ src/components/ui/GroupCard.tsx | 0 src/components/ui/VoteForm.tsx | 97 +++ src/components/ui/alert-dialog.tsx | 157 +++++ src/components/ui/alert.tsx | 59 ++ src/components/ui/auto-refresh-indicator.tsx | 52 ++ src/components/ui/badge.tsx | 36 + src/components/ui/chart-area-group-bets.tsx | 24 +- src/components/ui/custom-alert-dialog.tsx | 107 +++ src/components/ui/group-bets-table.tsx | 217 ++++++ src/components/ui/label.tsx | 26 + src/components/ui/site-header.tsx | 106 ++- src/components/ui/theme-provider.tsx | 9 + src/contexts/AuthContext.tsx | 210 ++++++ src/hooks/use-alert-dialog.ts | 78 +++ src/hooks/use-auto-refresh.ts | 142 ++++ src/lib/auth.ts | 45 ++ src/lib/db.ts | 24 + src/lib/email.ts | 95 +++ src/lib/store.ts | 366 ++++++++++ src/lib/types.ts | 72 ++ src/models/Bet.ts | 88 +++ src/models/Group.ts | 53 ++ src/models/User.ts | 39 ++ src/scripts/debug-bet.ts | 43 ++ src/scripts/fix-existing-votes.ts | 53 ++ src/scripts/fix-usernames.ts | 50 ++ 60 files changed, 6759 insertions(+), 103 deletions(-) create mode 100644 SETUP.md create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/api/bets/route.ts create mode 100644 src/app/forgot-password/page.tsx create mode 100644 src/app/reset-password/page.tsx create mode 100644 src/components/app-sidebar.tsx create mode 100644 src/components/site-header.tsx create mode 100644 src/components/theme-toggle.tsx create mode 100644 src/components/ui/BetForm.tsx create mode 100644 src/components/ui/GroupCard.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/auto-refresh-indicator.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/custom-alert-dialog.tsx create mode 100644 src/components/ui/group-bets-table.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/theme-provider.tsx create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/hooks/use-alert-dialog.ts create mode 100644 src/hooks/use-auto-refresh.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/email.ts create mode 100644 src/lib/store.ts create mode 100644 src/lib/types.ts create mode 100644 src/models/Bet.ts create mode 100644 src/models/Group.ts create mode 100644 src/models/User.ts create mode 100644 src/scripts/debug-bet.ts create mode 100644 src/scripts/fix-existing-votes.ts create mode 100644 src/scripts/fix-usernames.ts diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..fbeff9d --- /dev/null +++ b/SETUP.md @@ -0,0 +1,167 @@ +# Authentication Setup Guide + +## Environment Variables + +Create a `.env` file in your project root with the following variables: + +```env +# Email Configuration (Gmail) +EMAIL_USER=your-email@gmail.com +EMAIL_PASSWORD=your-app-password + +# JWT Secret (change this in production) +JWT_SECRET=49aa564ea09390f8dd2d5aeb0d1558c78c568a6f672e4abae188f40f08574ec396dccdc7eaefdc6848c59455ec58b14b4dcbf619cbce51ec6369baabec053d5f + +# App URL (for email links) +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# MongoDB Atlas Connection String (includes database name) +MONGODB_URI=mongodb+srv://rcommandur:Cu44ent2005@cluster0.vyw4kan.mongodb.net/friendssplit?retryWrites=true&w=majority&appName=Cluster0 +``` + +## MongoDB Atlas Setup + +### 1. Create MongoDB Atlas Account +1. Go to [MongoDB Atlas](https://www.mongodb.com/atlas) +2. Click "Try Free" or "Get Started Free" +3. Fill in your details and create an account + +### 2. Create a New Cluster +1. Click "Build a Database" +2. Choose "FREE" tier (M0) +3. Select your preferred cloud provider (AWS, Google Cloud, or Azure) +4. Choose a region close to you +5. Click "Create" + +### 3. Set Up Database Access +1. In the left sidebar, click "Database Access" +2. Click "Add New Database User" +3. Choose "Password" authentication +4. Create a username and password (save these!) +5. Set privileges to "Read and write to any database" +6. Click "Add User" + +### 4. Set Up Network Access +1. In the left sidebar, click "Network Access" +2. Click "Add IP Address" +3. For development, click "Allow Access from Anywhere" (0.0.0.0/0) +4. Click "Confirm" + +### 5. Get Your Connection String +1. Go back to "Database" in the sidebar +2. Click "Connect" +3. Choose "Connect your application" +4. Select "Node.js" driver +5. Copy the connection string and replace username/password +6. **Important**: Add `/friendssplit` after `.net/` to specify the database name + +## Database Creation + +**You don't need to manually create a database!** Mongoose will automatically: +- Create the `friendssplit` database when you first save data +- Create collections (`users`, `groups`, `bets`) when you first insert documents +- Set up proper indexes and validation + +## JWT Secret Generation + +I've generated a secure JWT secret for you above. If you want to generate a new one, you can: + +1. **Using Node.js** (if you have it installed): + ```bash + node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" + ``` + +2. **Using an online generator** (for development only): + - Visit https://generate-secret.vercel.app/64 + - Copy the generated string + +3. **Using PowerShell** (Windows): + ```powershell + $bytes = New-Object Byte[] 64 + (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) + [System.BitConverter]::ToString($bytes) -replace '-', '' + ``` + +**Important**: Use a different JWT secret for production environments! + +## Gmail App Password Setup + +To use Gmail for sending emails, you need to: + +1. Enable 2-Factor Authentication on your Google account +2. Generate an App Password: + - Go to Google Account settings + - Security → 2-Step Verification → App passwords + - Generate a new app password for "Mail" + - Use this password in your `EMAIL_PASSWORD` environment variable + +## Features Implemented + +### Authentication +- ✅ User registration with email verification +- ✅ User login with JWT tokens +- ✅ Password reset via email +- ✅ Secure password hashing with bcrypt +- ✅ JWT token management +- ✅ Authentication context for state management +- ✅ MongoDB Atlas integration + +### Database +- ✅ MongoDB Atlas connection +- ✅ User model with indexes +- ✅ Group model with relationships +- ✅ Bet model with voting system +- ✅ Proper data validation and constraints + +### UI Components +- ✅ Beautiful login page with form validation +- ✅ Comprehensive signup page with password strength validation +- ✅ Forgot password page +- ✅ Reset password page with token validation +- ✅ Updated site header with login/logout functionality +- ✅ Loading states and error handling + +### Email Integration +- ✅ Welcome emails for new users +- ✅ Password reset emails with secure tokens +- ✅ Professional email templates +- ✅ Token expiration (1 hour for reset tokens) + +### Security Features +- ✅ Password strength validation +- ✅ Secure token generation +- ✅ Email enumeration protection +- ✅ CSRF protection through proper form handling +- ✅ Input validation and sanitization + +## Usage + +1. **Registration**: Users can create accounts with username, email, and password +2. **Login**: Users can log in with email and password +3. **Password Reset**: Users can request password reset via email +4. **Session Management**: JWT tokens are stored in localStorage and automatically refreshed +5. **Logout**: Users can log out, which clears their session + +## Sample Users + +The system comes with two sample users for testing: + +- **Email**: john@example.com, **Password**: password123 +- **Email**: jane@example.com, **Password**: password123 + +## API Endpoints + +- `POST /api/auth/signup` - User registration +- `POST /api/auth/login` - User login +- `GET /api/auth/me` - Get current user profile +- `POST /api/auth/logout` - User logout +- `POST /api/auth/forgot-password` - Request password reset +- `POST /api/auth/reset-password` - Reset password with token + +## Next Steps + +1. Set up your environment variables in `.env` +2. Test the authentication flow with MongoDB +3. Customize email templates if needed +4. Add additional security measures for production +5. Implement email verification for new accounts (optional) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f1e93a9..263ad9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "my-app", "version": "0.1.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -16,12 +18,20 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^6.4.17", + "bcryptjs": "^3.0.2", "chart.js": "^4.4.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.514.0", + "mongodb": "^6.17.0", + "mongoose": "^8.16.1", "next": "15.3.3", "next-themes": "^0.4.6", + "nodemailer": "^7.0.3", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", @@ -824,6 +834,15 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", @@ -1041,6 +1060,34 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1256,6 +1303,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -2076,6 +2146,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -2160,16 +2236,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2190,6 +2290,21 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.34.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", @@ -3023,6 +3138,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3047,6 +3171,21 @@ "node": ">=8" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3463,7 +3602,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3580,6 +3718,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5221,6 +5368,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5237,6 +5406,36 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5542,6 +5741,42 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5549,6 +5784,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5590,6 +5831,12 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5676,11 +5923,109 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mongodb": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", + "integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.17.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5816,6 +6161,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6129,7 +6483,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6480,6 +6833,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6525,7 +6898,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6724,6 +7096,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6743,6 +7121,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7066,6 +7453,18 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -7236,7 +7635,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -7347,6 +7745,28 @@ "d3-timer": "^3.0.1" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 40ca531..ebeea0c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -17,12 +19,20 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^6.4.17", + "bcryptjs": "^3.0.2", "chart.js": "^4.4.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.514.0", + "mongodb": "^6.17.0", + "mongoose": "^8.16.1", "next": "15.3.3", "next-themes": "^0.4.6", + "nodemailer": "^7.0.3", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..e3c275f --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,57 @@ +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import { sendEmail, generatePasswordResetEmail } from '@/lib/email'; +import crypto from 'crypto'; +import { NextRequest } from 'next/server'; + +// POST /api/auth/forgot-password - Request password reset +export async function POST(req: NextRequest) { + try { + await connectDB(); + + const body = await req.json(); + const { email } = body; + + if (!email) { + return Response.json({ + error: 'Email is required' + }, { status: 400 }); + } + + // Find user by email + const user = await User.findOne({ email }); + if (!user) { + // Always return success to prevent email enumeration + return Response.json({ + message: 'If an account with that email exists, a password reset link has been sent.' + }); + } + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString('hex'); + const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + // Update user with reset token + await User.findByIdAndUpdate(user._id, { + resetToken, + resetTokenExpiry + }); + + // Send reset email + try { + await sendEmail(generatePasswordResetEmail(email, resetToken)); + } catch (error) { + console.error('Failed to send reset email:', error); + } + + // Always return success to prevent email enumeration + return Response.json({ + message: 'If an account with that email exists, a password reset link has been sent.' + }); + } catch (error) { + console.error('Error requesting password reset:', error); + return Response.json({ + message: 'If an account with that email exists, a password reset link has been sent.' + }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 83c014a..fa98c81 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,52 @@ -// POST /api/auth/login -export async function POST(req: Request) { - // TODO: Implement login logic - return new Response(JSON.stringify({ message: 'Login endpoint' }), { status: 200 }); +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import { comparePassword, generateToken } from '@/lib/auth'; +import { NextRequest } from 'next/server'; + +// POST /api/auth/login - Login user +export async function POST(req: NextRequest) { + try { + await connectDB(); + + const body = await req.json(); + const { email, password } = body; + + if (!email || !password) { + return Response.json({ + error: 'Email and password are required' + }, { status: 400 }); + } + + // Find user by email + const user = await User.findOne({ email }); + if (!user) { + return Response.json({ + error: 'Invalid email or password' + }, { status: 401 }); + } + + // Verify password + const isValidPassword = await comparePassword(password, user.password); + if (!isValidPassword) { + return Response.json({ + error: 'Invalid email or password' + }, { status: 401 }); + } + + // Generate JWT token + const token = generateToken({ + userId: user._id.toString(), + username: user.username, + email: user.email, + }); + + return Response.json({ + token, + userId: user._id, + username: user.username + }); + } catch (error) { + console.error('Error logging in:', error); + return Response.json({ error: 'Failed to login' }, { status: 500 }); + } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 21d014b..2d0cb4b 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,5 +1,14 @@ -// POST /api/auth/logout -export async function POST(req: Request) { - // TODO: Implement logout logic - return new Response(JSON.stringify({ message: 'Logout endpoint' }), { status: 200 }); +import { NextRequest } from 'next/server'; + +// POST /api/auth/logout - Logout user +export async function POST(req: NextRequest) { + try { + // TODO: Invalidate JWT token (add to blacklist or set expiry) + // For now, just return success message + + return Response.json({ message: 'Logged out' }); + } catch (error) { + console.error('Error logging out:', error); + return Response.json({ error: 'Failed to logout' }, { status: 500 }); + } } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index cfeb171..009ebd8 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,5 +1,52 @@ -// GET /api/auth/me -export async function GET(req: Request) { - // TODO: Implement get current user logic - return new Response(JSON.stringify({ message: 'Me endpoint' }), { status: 200 }); +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import Group from '@/models/Group'; +import Bet from '@/models/Bet'; +import { getUserFromToken } from '@/lib/auth'; +import { NextRequest } from 'next/server'; + +// GET /api/auth/me - Get current user profile +export async function GET(req: NextRequest) { + try { + await connectDB(); + + // Extract and verify JWT token + const authHeader = req.headers.get('authorization'); + const userPayload = getUserFromToken(authHeader); + + if (!userPayload) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await User.findById(userPayload.userId); + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // Get user's groups + const groups = await Group.find({ members: user._id }).populate('members', 'username'); + + // Get bets for each group + const groupsWithBets = await Promise.all( + groups.map(async (group) => { + const bets = await Bet.find({ groupId: group._id }).populate('createdBy', 'username'); + return { + groupId: group._id, + name: group.name, + members: group.members, + bets: bets + }; + }) + ); + + return Response.json({ + userId: user._id, + username: user.username, + email: user.email, + groups: groupsWithBets + }); + } catch (error) { + console.error('Error fetching user profile:', error); + return Response.json({ error: 'Failed to fetch user profile' }, { status: 500 }); + } } diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..d94cd3e --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,56 @@ +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import { hashPassword } from '@/lib/auth'; +import { NextRequest } from 'next/server'; + +// POST /api/auth/reset-password - Reset password with token +export async function POST(req: NextRequest) { + try { + await connectDB(); + + const body = await req.json(); + const { token, password } = body; + + if (!token || !password) { + return Response.json({ + error: 'Token and new password are required' + }, { status: 400 }); + } + + // Validate password strength + if (password.length < 6) { + return Response.json({ + error: 'Password must be at least 6 characters long' + }, { status: 400 }); + } + + // Find user with valid reset token + const user = await User.findOne({ + resetToken: token, + resetTokenExpiry: { $gt: new Date() } + }); + + if (!user) { + return Response.json({ + error: 'Invalid or expired reset token' + }, { status: 400 }); + } + + // Hash new password + const hashedPassword = await hashPassword(password); + + // Update user password and clear reset token + await User.findByIdAndUpdate(user._id, { + password: hashedPassword, + resetToken: null, + resetTokenExpiry: null + }); + + return Response.json({ + message: 'Password has been reset successfully' + }); + } catch (error) { + console.error('Error resetting password:', error); + return Response.json({ error: 'Failed to reset password' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 000925b..435437a 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,5 +1,66 @@ -// POST /api/auth/signup -export async function POST(req: Request) { - // TODO: Implement signup logic - return new Response(JSON.stringify({ message: 'Signup endpoint' }), { status: 200 }); +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import { hashPassword, generateToken } from '@/lib/auth'; +import { NextRequest } from 'next/server'; + +// POST /api/auth/signup - Create new user account +export async function POST(req: NextRequest) { + try { + await connectDB(); + + const body = await req.json(); + const { username, email, password } = body; + + if (!username || !email || !password) { + return Response.json({ + error: 'Username, email, and password are required' + }, { status: 400 }); + } + + // Validate password strength + if (password.length < 6) { + return Response.json({ + error: 'Password must be at least 6 characters long' + }, { status: 400 }); + } + + // Check if user already exists + const existingUser = await User.findOne({ + $or: [{ email }, { username }] + }); + + if (existingUser) { + return Response.json({ + error: existingUser.email === email + ? 'User with this email already exists' + : 'Username already taken' + }, { status: 409 }); + } + + // Hash password + const hashedPassword = await hashPassword(password); + + // Create new user + const user = await User.create({ + username, + email, + password: hashedPassword, + }); + + // Generate JWT token + const token = generateToken({ + userId: user._id.toString(), + username: user.username, + email: user.email, + }); + + return Response.json({ + userId: user._id, + token, + username: user.username + }); + } catch (error) { + console.error('Error creating user:', error); + return Response.json({ error: 'Failed to create user' }, { status: 500 }); + } } diff --git a/src/app/api/bets/[betId]/outcome/route.ts b/src/app/api/bets/[betId]/outcome/route.ts index 1dba3dd..c4b434c 100644 --- a/src/app/api/bets/[betId]/outcome/route.ts +++ b/src/app/api/bets/[betId]/outcome/route.ts @@ -1,5 +1,116 @@ -// POST /api/bets/:betId/outcome -export async function POST(req: Request, { params }: { params: { betId: string } }) { - // TODO: Implement declare outcome logic - return new Response(JSON.stringify({ message: `Declare outcome for bet ${params.betId}` }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + +// POST /api/bets/:betId/outcome - Declare bet outcome and calculate payouts +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ betId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const { winningOptionId } = body; + + if (!winningOptionId) { + return Response.json({ error: 'Winning option ID is required' }, { status: 400 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { betId } = await params; + + const bet = await Bet.findById(betId).populate('groupId'); + if (!bet) { + return Response.json({ error: 'Bet not found' }, { status: 404 }); + } + + if (bet.status === 'settled') { + return Response.json({ error: 'Bet has already been settled' }, { status: 400 }); + } + + // Check if user is a moderator of the group + const group = await Group.findById(bet.groupId); + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + const isModerator = group.moderators.some((modId: any) => modId.toString() === decoded.userId); + const isOwner = group.ownerId.toString() === decoded.userId; + + if (!isModerator && !isOwner) { + return Response.json({ error: 'Only moderators can declare outcomes' }, { status: 403 }); + } + + // Check if the winning option exists + const winningOptionIndex = bet.options.findIndex((opt: any) => + opt._id.toString() === winningOptionId || + `${bet._id}-option-${bet.options.indexOf(opt) + 1}` === winningOptionId + ); + + if (winningOptionIndex === -1) { + return Response.json({ error: 'Invalid winning option' }, { status: 400 }); + } + + const winningOption = bet.options[winningOptionIndex]; + + // Close the bet if it's still open + if (bet.status === 'open') { + bet.status = 'closed'; + } + + // Settle the bet + bet.status = 'settled'; + bet.winningOption = winningOptionIndex; + await bet.save(); + + // Calculate payouts + const totalPool = bet.options.reduce((total: number, option: any) => + total + option.votes.reduce((sum: number, vote: any) => sum + vote.stake, 0), 0 + ); + + const winningVotes = winningOption.votes; + const losingVotes = bet.options.flatMap((option: any, index: number) => + index === winningOptionIndex ? [] : option.votes + ); + + const result = { + totalPool, + winningOptionId: winningOption._id.toString(), + winningOptionText: winningOption.text, + winners: winningVotes.map((vote: any) => ({ + userId: vote.userId.toString(), + stake: vote.stake, + payout: totalPool * (vote.stake / winningVotes.reduce((sum: number, v: any) => sum + v.stake, 0)) + })), + losers: losingVotes.map((vote: any) => ({ + userId: vote.userId.toString(), + stake: vote.stake, + loss: vote.stake + })) + }; + + return Response.json({ + result, + message: 'Bet settled successfully', + winningOption: winningOption.text + }); + } catch (error) { + console.error('Error settling bet:', error); + return Response.json({ error: 'Failed to settle bet' }, { status: 500 }); + } } diff --git a/src/app/api/bets/[betId]/payouts/route.ts b/src/app/api/bets/[betId]/payouts/route.ts index e2e5323..eeb155d 100644 --- a/src/app/api/bets/[betId]/payouts/route.ts +++ b/src/app/api/bets/[betId]/payouts/route.ts @@ -1,5 +1,71 @@ -// GET /api/bets/:betId/payouts -export async function GET(req: Request, { params }: { params: { betId: string } }) { - // TODO: Implement get payouts logic - return new Response(JSON.stringify({ message: `Get payouts for bet ${params.betId}` }), { status: 200 }); -} +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/bets/:betId/payouts - Get bet results and payouts +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ betId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { betId } = await params; + + const bet = await Bet.findById(betId); + + if (!bet) { + return Response.json({ error: 'Bet not found' }, { status: 404 }); + } + + if (bet.status !== 'settled') { + return Response.json({ error: 'Bet has not been settled yet' }, { status: 400 }); + } + + // Calculate payouts + const totalPool = bet.options.reduce((total, option) => + total + option.votes.reduce((sum, vote) => sum + vote.stake, 0), 0 + ); + + const winningOption = bet.options[bet.winningOption!]; + const winningVotes = winningOption.votes; + const losingVotes = bet.options.flatMap((option, index) => + index === bet.winningOption ? [] : option.votes + ); + + const result = { + totalPool, + winningOptionId: winningOption._id.toString(), + winningOptionText: winningOption.text, + winners: winningVotes.map((vote: any) => ({ + userId: vote.userId.toString(), + stake: vote.stake, + payout: totalPool * (vote.stake / winningVotes.reduce((sum, v) => sum + v.stake, 0)) + })), + losers: losingVotes.map((vote: any) => ({ + userId: vote.userId.toString(), + stake: vote.stake, + loss: vote.stake + })) + }; + + return Response.json({ result }); + } catch (error) { + console.error('Error fetching bet result:', error); + return Response.json({ error: 'Failed to fetch bet result' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/bets/[betId]/route.ts b/src/app/api/bets/[betId]/route.ts index 713dafa..a85dd00 100644 --- a/src/app/api/bets/[betId]/route.ts +++ b/src/app/api/bets/[betId]/route.ts @@ -1,5 +1,156 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + // GET /api/bets/:betId -export async function GET(req: Request, { params }: { params: { betId: string } }) { - // TODO: Implement get bet details logic - return new Response(JSON.stringify({ message: `Bet details for ${params.betId}` }), { status: 200 }); +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ betId: string }> } +) { + try { + const { betId } = await params; + await connectDB(); + + const bet = await Bet.findById(betId).populate('groupId'); + if (!bet) { + return NextResponse.json({ error: 'Bet not found' }, { status: 404 }); + } + + // Transform MongoDB document to frontend format + const transformedBet = { + id: bet._id.toString(), + title: bet.title, + description: bet.description, + options: bet.options.map((option: any) => ({ + id: option._id.toString(), + text: option.text, + votes: option.votes.map((vote: any) => ({ + userId: vote.userId.toString(), + username: vote.username, + stake: vote.stake, + timestamp: vote.timestamp + })) + })), + deadline: bet.deadline, + minStake: bet.minStake, + maxStake: bet.maxStake, + status: bet.status, + groupId: bet.groupId._id.toString(), + createdAt: bet.createdAt, + updatedAt: bet.updatedAt + }; + + return NextResponse.json({ bet: transformedBet }); + } catch (error) { + console.error('Error fetching bet:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ betId: string }> } +) { + try { + const { betId } = await params; + const body = await request.json(); + const token = request.headers.get('authorization')?.replace('Bearer ', ''); + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await verifyToken(token); + if (!user) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + const bet = await Bet.findById(betId); + if (!bet) { + return NextResponse.json({ error: 'Bet not found' }, { status: 404 }); + } + + // Check if user is moderator of the group + const group = await Group.findById(bet.groupId); + if (!group) { + return NextResponse.json({ error: 'Group not found' }, { status: 404 }); + } + + const isModerator = group.ownerId.toString() === user.userId || + group.moderators.includes(user.userId); + + if (!isModerator) { + return NextResponse.json({ error: 'Only moderators can edit bets' }, { status: 403 }); + } + + // Only allow editing if bet is still open + if (bet.status !== 'open') { + return NextResponse.json({ error: 'Cannot edit closed or settled bets' }, { status: 400 }); + } + + // Update allowed fields + const allowedUpdates = ['title', 'description', 'deadline', 'minStake', 'maxStake']; + const updates: any = {}; + + for (const field of allowedUpdates) { + if (body[field] !== undefined) { + updates[field] = body[field]; + } + } + + // Validate stake limits + if (updates.minStake !== undefined || updates.maxStake !== undefined) { + const minStake = updates.minStake ?? bet.minStake; + const maxStake = updates.maxStake ?? bet.maxStake; + + if (minStake > maxStake) { + return NextResponse.json({ error: 'Min stake cannot be greater than max stake' }, { status: 400 }); + } + + if (minStake < group.minStake || maxStake > group.maxStake) { + return NextResponse.json({ + error: `Stake must be between £${group.minStake} and £${group.maxStake}` + }, { status: 400 }); + } + } + + const updatedBet = await Bet.findByIdAndUpdate( + betId, + updates, + { new: true } + ).populate('groupId'); + + // Transform response + const transformedBet = { + id: updatedBet._id.toString(), + title: updatedBet.title, + description: updatedBet.description, + options: updatedBet.options.map((option: any) => ({ + id: option._id.toString(), + text: option.text, + votes: option.votes.map((vote: any) => ({ + userId: vote.userId.toString(), + username: vote.username, + stake: vote.stake, + timestamp: vote.timestamp + })) + })), + deadline: updatedBet.deadline, + minStake: updatedBet.minStake, + maxStake: updatedBet.maxStake, + status: updatedBet.status, + groupId: updatedBet.groupId._id.toString(), + createdAt: updatedBet.createdAt, + updatedAt: updatedBet.updatedAt + }; + + return NextResponse.json({ bet: transformedBet }); + } catch (error) { + console.error('Error updating bet:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } } diff --git a/src/app/api/bets/[betId]/vote/route.ts b/src/app/api/bets/[betId]/vote/route.ts index 8b98110..447953c 100644 --- a/src/app/api/bets/[betId]/vote/route.ts +++ b/src/app/api/bets/[betId]/vote/route.ts @@ -1,5 +1,130 @@ -// POST /api/bets/:betId/vote -export async function POST(req: Request, { params }: { params: { betId: string } }) { - // TODO: Implement vote logic - return new Response(JSON.stringify({ message: `Vote on bet ${params.betId}` }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import Group from '@/models/Group'; +import User from '@/models/User'; +import { verifyToken } from '@/lib/auth'; + +// POST /api/bets/:betId/vote - Place a vote on a bet +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ betId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const { optionId, stake } = body; + + if (!optionId || !stake) { + return Response.json({ error: 'Option ID and stake are required' }, { status: 400 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { betId } = await params; + + const bet = await Bet.findById(betId).populate('groupId'); + if (!bet) { + return Response.json({ error: 'Bet not found' }, { status: 404 }); + } + + if (bet.status !== 'open') { + return Response.json({ error: 'Bet is not open for voting' }, { status: 400 }); + } + + if (bet.deadline < new Date()) { + return Response.json({ error: 'Bet deadline has passed' }, { status: 400 }); + } + + if (stake < bet.minStake || stake > bet.maxStake) { + return Response.json({ + error: `Stake must be between £${bet.minStake} and £${bet.maxStake}` + }, { status: 400 }); + } + + // Check if user is a member of the group + const group = await Group.findById(bet.groupId); + if (!group || !group.members.some((memberId: any) => memberId.toString() === decoded.userId)) { + return Response.json({ error: 'You must be a member of the group to vote' }, { status: 403 }); + } + + // Check if user has already voted on this bet and remove their previous vote + const existingVoteIndex = bet.options.findIndex((option: any) => + option.votes.some((vote: any) => vote.userId.toString() === decoded.userId) + ); + + if (existingVoteIndex !== -1) { + // Remove the existing vote + bet.options[existingVoteIndex].votes = bet.options[existingVoteIndex].votes.filter( + (vote: any) => vote.userId.toString() !== decoded.userId + ); + } + + // Get user details to store username + const user = await User.findById(decoded.userId); + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // Check if user has username + if (!user.username) { + console.error('User missing username:', { userId: decoded.userId, user: user.toObject() }); + return Response.json({ error: 'User profile is incomplete - username is required' }, { status: 400 }); + } + + console.log('User found for vote:', { userId: decoded.userId, username: user.username }); + + // Find the option and add the vote + const optionIndex = bet.options.findIndex((option: any) => + option._id.toString() === optionId || `${bet._id}-option-${bet.options.indexOf(option) + 1}` === optionId + ); + + if (optionIndex === -1) { + return Response.json({ error: 'Invalid option ID' }, { status: 400 }); + } + + // Create vote object with explicit typing + const voteData = { + userId: decoded.userId, + username: user.username, + stake: stake, + timestamp: new Date() + }; + + bet.options[optionIndex].votes.push(voteData); + + try { + await bet.save(); + } catch (saveError: any) { + console.error('Error saving bet with vote:', saveError); + if (saveError.name === 'ValidationError') { + console.error('Validation errors:', saveError.errors); + return Response.json({ + error: 'Validation failed', + details: Object.keys(saveError.errors).map(key => ({ + field: key, + message: saveError.errors[key].message + })) + }, { status: 400 }); + } + throw saveError; + } + + return Response.json({ message: 'Vote placed successfully' }); + } catch (error) { + console.error('Error placing vote:', error); + return Response.json({ error: 'Failed to place vote' }, { status: 500 }); + } } diff --git a/src/app/api/bets/route.ts b/src/app/api/bets/route.ts new file mode 100644 index 0000000..50cfa47 --- /dev/null +++ b/src/app/api/bets/route.ts @@ -0,0 +1,144 @@ +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/bets - Get all bets for the current user +export async function GET(req: NextRequest) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Find bets where the user is a member of the group + const userBets = await Bet.find({ + groupId: { $in: await Group.distinct('_id', { members: decoded.userId }) } + }).populate('groupId', 'name').populate('createdBy', 'username'); + + // Transform MongoDB _id to id for frontend compatibility + const transformedBets = userBets.map(bet => ({ + ...bet.toObject(), + id: bet._id.toString(), + _id: bet._id, + options: bet.options.map((option: any, index: number) => ({ + ...option.toObject(), + id: `${bet._id}-option-${index + 1}`, + _id: option._id, + votes: option.votes.map((vote: any) => ({ + userId: vote.userId.toString(), + username: vote.username, + stake: vote.stake, + timestamp: vote.timestamp + })) + })) + })); + + return Response.json({ bets: transformedBets }); + } catch (error) { + console.error('Error fetching bets:', error); + return Response.json({ error: 'Failed to fetch bets' }, { status: 500 }); + } +} + +// POST /api/bets - Create a new bet +export async function POST(req: NextRequest) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const { + groupId, + title, + description, + options, + deadline, + minStake, + maxStake + } = body; + + if (!groupId || !title || !description || !options || !deadline) { + return Response.json({ + error: 'Group ID, title, description, options, and deadline are required' + }, { status: 400 }); + } + + if (!Array.isArray(options) || options.length < 2) { + return Response.json({ + error: 'At least 2 options are required' + }, { status: 400 }); + } + + await connectDB(); + + // Check if user is a moderator of the group + const group = await Group.findById(groupId); + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + // Check if user is a moderator or owner + const isModerator = group.moderators.some((modId: any) => modId.toString() === decoded.userId); + const isOwner = group.ownerId.toString() === decoded.userId; + + if (!isModerator && !isOwner) { + return Response.json({ error: 'Only moderators can create bets' }, { status: 403 }); + } + + const betOptions = options.map((option: string) => ({ + text: option, + votes: [], + })); + + const bet = new Bet({ + groupId, + title, + description, + options: betOptions, + deadline: new Date(deadline), + status: 'open', + minStake: minStake || group.minStake, + maxStake: maxStake || group.maxStake, + createdBy: decoded.userId, + }); + + await bet.save(); + + // Transform MongoDB _id to id for frontend compatibility + const transformedBet = { + ...bet.toObject(), + id: bet._id.toString(), + _id: bet._id, + options: bet.options.map((option: any, index: number) => ({ + ...option.toObject(), + id: `${bet._id}-option-${index + 1}`, + _id: option._id + })) + }; + + return Response.json({ bet: transformedBet }); + } catch (error) { + console.error('Error creating bet:', error); + return Response.json({ error: 'Failed to create bet' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/groups/[groupId]/bets/route.ts b/src/app/api/groups/[groupId]/bets/route.ts index 2a124ee..3f22820 100644 --- a/src/app/api/groups/[groupId]/bets/route.ts +++ b/src/app/api/groups/[groupId]/bets/route.ts @@ -1,10 +1,69 @@ -// GET /api/groups/:groupId/bets, POST /api/groups/:groupId/bets -export async function GET(req: Request, { params }: { params: { groupId: string } }) { - // TODO: Implement get all bets in group logic - return new Response(JSON.stringify({ message: `Get bets for group ${params.groupId}` }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Bet from '@/models/Bet'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/groups/:groupId/bets - Get all bets for a group +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ groupId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + const { groupId } = await params; + + // Check if user is a member of the group + const group = await Group.findById(groupId); + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + if (!group.members.some((memberId: any) => memberId.toString() === decoded.userId)) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + + const bets = await Bet.find({ groupId }).populate('createdBy', 'username'); + + // Transform MongoDB _id to id for frontend compatibility + const transformedBets = bets.map(bet => ({ + ...bet.toObject(), + id: bet._id.toString(), + _id: bet._id, + options: bet.options.map((option: any, index: number) => ({ + ...option.toObject(), + id: `${bet._id}-option-${index + 1}`, + _id: option._id, + votes: option.votes.map((vote: any) => ({ + userId: vote.userId.toString(), + username: vote.username, + stake: vote.stake, + timestamp: vote.timestamp + })) + })) + })); + + return Response.json({ bets: transformedBets }); + } catch (error) { + console.error('Error fetching group bets:', error); + return Response.json({ error: 'Failed to fetch group bets' }, { status: 500 }); + } } -export async function POST(req: Request, { params }: { params: { groupId: string } }) { - // TODO: Implement create bet logic - return new Response(JSON.stringify({ message: `Create bet in group ${params.groupId}` }), { status: 200 }); +export async function POST(req: NextRequest, { params }: { params: Promise<{ groupId: string }> }) { + // This is handled by the main /api/bets route + return Response.json({ error: 'Use /api/bets to create bets' }, { status: 400 }); } diff --git a/src/app/api/groups/[groupId]/members/[userId]/route.ts b/src/app/api/groups/[groupId]/members/[userId]/route.ts index 3917693..7ebf8ee 100644 --- a/src/app/api/groups/[groupId]/members/[userId]/route.ts +++ b/src/app/api/groups/[groupId]/members/[userId]/route.ts @@ -1,5 +1,89 @@ -// DELETE /api/groups/:groupId/members/:userId -export async function DELETE(req: Request, { params }: { params: { groupId: string, userId: string } }) { - // TODO: Implement remove member logic - return new Response(JSON.stringify({ message: `Remove user ${params.userId} from group ${params.groupId}` }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Group from '@/models/Group'; +import Bet from '@/models/Bet'; +import { verifyToken } from '@/lib/auth'; + +// DELETE /api/groups/:groupId/members/:userId - Remove user from group +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ groupId: string; userId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + // Await params for Next.js 15 compatibility + const { groupId, userId } = await params; + + // Verify the user is trying to leave their own membership + if (decoded.userId !== userId) { + return Response.json({ error: 'Unauthorized' }, { status: 403 }); + } + + await connectDB(); + + const group = await Group.findById(groupId); + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + // Check if user is a member + if (!group.members.includes(userId)) { + return Response.json({ error: 'Not a member of this group' }, { status: 400 }); + } + + // Remove user's votes from all active bets in the group + const activeBets = await Bet.find({ + groupId: groupId, + status: { $in: ['open', 'closed'] } // Only active bets, not settled ones + }); + + for (const bet of activeBets) { + let betModified = false; + + // Remove user's votes from all options + for (const option of bet.options) { + const originalVoteCount = option.votes.length; + option.votes = option.votes.filter((vote: any) => vote.userId.toString() !== userId); + + if (option.votes.length !== originalVoteCount) { + betModified = true; + } + } + + // Save the bet if votes were removed + if (betModified) { + await bet.save(); + } + } + + // Remove user from group + group.members = group.members.filter((memberId: any) => memberId.toString() !== userId); + + // If no members left, delete the group + if (group.members.length === 0) { + await Group.findByIdAndDelete(groupId); + return Response.json({ message: 'Group deleted (no members left)' }); + } + + await group.save(); + + return Response.json({ + message: 'Successfully left group', + votesRemoved: activeBets.length > 0 + }); + } catch (error) { + console.error('Error removing member from group:', error); + return Response.json({ error: 'Failed to remove member from group' }, { status: 500 }); + } } diff --git a/src/app/api/groups/[groupId]/members/route.ts b/src/app/api/groups/[groupId]/members/route.ts index 07ac08a..85808fb 100644 --- a/src/app/api/groups/[groupId]/members/route.ts +++ b/src/app/api/groups/[groupId]/members/route.ts @@ -1,5 +1,40 @@ -// POST /api/groups/:groupId/members -export async function POST(req: Request, { params }: { params: { groupId: string } }) { - // TODO: Implement add member logic - return new Response(JSON.stringify({ message: `Add member to group ${params.groupId}` }), { status: 200 }); +import { dataStore } from '@/lib/store'; +import { NextRequest } from 'next/server'; + +// POST /api/groups/:groupId/members - Add a user to a group +export async function POST( + req: NextRequest, + { params }: { params: { groupId: string } } +) { + try { + const body = await req.json(); + const { userId } = body; + + if (!userId) { + return Response.json({ error: 'User ID is required' }, { status: 400 }); + } + + // Check if group exists + const group = dataStore.getGroup(params.groupId); + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + // Check if user exists + const user = dataStore.getUser(userId); + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // Add user to group + const success = dataStore.addMemberToGroup(params.groupId, userId); + if (!success) { + return Response.json({ error: 'User is already a member of this group' }, { status: 400 }); + } + + return Response.json({ success: true }); + } catch (error) { + console.error('Error adding member to group:', error); + return Response.json({ error: 'Failed to add member to group' }, { status: 500 }); + } } diff --git a/src/app/api/groups/[groupId]/route.ts b/src/app/api/groups/[groupId]/route.ts index 7d068a1..06ed412 100644 --- a/src/app/api/groups/[groupId]/route.ts +++ b/src/app/api/groups/[groupId]/route.ts @@ -1,5 +1,52 @@ -// GET /api/groups/:groupId -export async function GET(req: Request, { params }: { params: { groupId: string } }) { - // TODO: Implement get group details logic - return new Response(JSON.stringify({ message: `Group details for ${params.groupId}` }), { status: 200 }); -} +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/groups/:groupId - Get a specific group +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ groupId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { groupId } = await params; + + const group = await Group.findById(groupId).populate('members', 'username email'); + + if (!group) { + return Response.json({ error: 'Group not found' }, { status: 404 }); + } + + // Check if user is a member of this group + if (!group.members.some((member: any) => member._id.toString() === decoded.userId)) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + + // Transform MongoDB _id to id for frontend compatibility + const transformedGroup = { + ...group.toObject(), + id: group._id.toString(), + _id: group._id + }; + + return Response.json({ group: transformedGroup }); + } catch (error) { + console.error('Error fetching group:', error); + return Response.json({ error: 'Failed to fetch group' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/groups/join/route.ts b/src/app/api/groups/join/route.ts index 45d4acd..df7e1ab 100644 --- a/src/app/api/groups/join/route.ts +++ b/src/app/api/groups/join/route.ts @@ -1,5 +1,59 @@ -// POST /api/groups/join -export async function POST(req: Request) { - // TODO: Implement join group by code logic - return new Response(JSON.stringify({ message: 'Join group by code endpoint' }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Group from '@/models/Group'; +import { verifyToken } from '@/lib/auth'; + +// POST /api/groups/join - Join a group by code +export async function POST(req: NextRequest) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const { code } = body; + + if (!code) { + return Response.json({ error: 'Group code is required' }, { status: 400 }); + } + + await connectDB(); + + const group = await Group.findOne({ code: code.toUpperCase() }); + if (!group) { + return Response.json({ error: 'Invalid group code' }, { status: 404 }); + } + + // Check if user is already a member + if (group.members.includes(decoded.userId)) { + return Response.json({ error: 'Already a member of this group' }, { status: 400 }); + } + + // Add user to group + group.members.push(decoded.userId); + await group.save(); + + // Populate members data + await group.populate('members', 'username email'); + + // Transform MongoDB _id to id for frontend compatibility + const transformedGroup = { + ...group.toObject(), + id: group._id.toString(), + _id: group._id + }; + + return Response.json({ group: transformedGroup, message: 'Successfully joined group' }); + } catch (error) { + console.error('Error joining group:', error); + return Response.json({ error: 'Failed to join group' }, { status: 500 }); + } } diff --git a/src/app/api/groups/route.ts b/src/app/api/groups/route.ts index 6cd5a55..cae0402 100644 --- a/src/app/api/groups/route.ts +++ b/src/app/api/groups/route.ts @@ -1,10 +1,115 @@ -// GET /api/groups, POST /api/groups -export async function GET(req: Request) { - // TODO: Implement get all groups logic - return new Response(JSON.stringify({ message: 'Groups GET endpoint' }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import Group from '@/models/Group'; +import User from '@/models/User'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/groups - Get all groups for the current user +export async function GET(req: NextRequest) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Find groups where the user is a member + const groups = await Group.find({ + members: decoded.userId + }).populate('members', 'username email'); + + // Transform MongoDB _id to id for frontend compatibility + const transformedGroups = groups.map(group => ({ + ...group.toObject(), + id: group._id.toString(), + _id: group._id + })); + + return Response.json({ groups: transformedGroups }); + } catch (error) { + console.error('Error fetching groups:', error); + return Response.json({ error: 'Failed to fetch groups' }, { status: 500 }); + } } -export async function POST(req: Request) { - // TODO: Implement create group logic - return new Response(JSON.stringify({ message: 'Groups POST endpoint' }), { status: 200 }); +// POST /api/groups - Create a new group +export async function POST(req: NextRequest) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const { name, description, minStake = 1, maxStake = 100 } = body; + + if (!name || !description) { + return Response.json({ error: 'Name and description are required' }, { status: 400 }); + } + + await connectDB(); + + // Generate a unique 6-character code + const generateCode = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + let code; + let isUnique = false; + while (!isUnique) { + code = generateCode(); + const existingGroup = await Group.findOne({ code }); + if (!existingGroup) { + isUnique = true; + } + } + + const group = new Group({ + name, + description, + code, + ownerId: decoded.userId, + moderators: [decoded.userId], + members: [decoded.userId], + minStake, + maxStake, + }); + + await group.save(); + + // Populate members data + await group.populate('members', 'username email'); + + // Transform MongoDB _id to id for frontend compatibility + const transformedGroup = { + ...group.toObject(), + id: group._id.toString(), + _id: group._id + }; + + return Response.json({ group: transformedGroup }); + } catch (error) { + console.error('Error creating group:', error); + return Response.json({ error: 'Failed to create group' }, { status: 500 }); + } } diff --git a/src/app/api/users/[userId]/bets/route.ts b/src/app/api/users/[userId]/bets/route.ts index 7f6c4d5..832e41c 100644 --- a/src/app/api/users/[userId]/bets/route.ts +++ b/src/app/api/users/[userId]/bets/route.ts @@ -1,5 +1,93 @@ -// GET /api/users/:userId/bets -export async function GET(req: Request, { params }: { params: { userId: string } }) { - // TODO: Implement get user bets logic - return new Response(JSON.stringify({ message: `Get bets for user ${params.userId}` }), { status: 200 }); -} +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import Bet from '@/models/Bet'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/users/:userId/bets - Get bets user has participated in +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { userId } = await params; + + const user = await User.findById(userId); + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // Get bets where user has voted + const betsWithVotes = await Bet.aggregate([ + { $unwind: '$options' }, + { $unwind: '$options.votes' }, + { $match: { 'options.votes.userId': userId } }, + { $group: { + _id: '$_id', + title: { $first: '$title' }, + status: { $first: '$status' }, + deadline: { $first: '$deadline' }, + winningOption: { $first: '$winningOption' }, + userVote: { + $push: { + optionId: '$options._id', + stake: '$options.votes.stake' + } + } + }} + ]); + + // Create detailed bet participation records + const betParticipation = betsWithVotes.map(bet => { + const userVote = bet.userVote[0]; // User can only vote once per bet + + let result = 'pending'; + let stake = userVote.stake; + let payout = 0; + + if (bet.status === 'settled') { + const votedOptionId = userVote.optionId.toString(); + const winningOptionId = bet.winningOption !== null ? + bet._id.toString() + '-option-' + (bet.winningOption + 1) : null; + + if (votedOptionId === winningOptionId) { + result = 'won'; + // Calculate payout (simplified - in real app you'd store this) + payout = stake * 1.5; // Placeholder calculation + } else { + result = 'lost'; + } + } + + return { + betId: bet._id.toString(), + title: bet.title, + result, + stake, + payout: payout.toFixed(2), + status: bet.status, + deadline: bet.deadline + }; + }); + + return Response.json(betParticipation); + } catch (error) { + console.error('Error fetching user bets:', error); + return Response.json({ error: 'Failed to fetch user bets' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index 5dc609d..ec272ec 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -1,5 +1,71 @@ -// GET /api/users/:userId -export async function GET(req: Request, { params }: { params: { userId: string } }) { - // TODO: Implement get user profile logic - return new Response(JSON.stringify({ message: `User profile for ${params.userId}` }), { status: 200 }); +import { NextRequest } from 'next/server'; +import connectDB from '@/lib/db'; +import User from '@/models/User'; +import Bet from '@/models/Bet'; +import { verifyToken } from '@/lib/auth'; + +// GET /api/users/:userId - Get public profile info +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + // Get authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return Response.json({ error: 'Authentication required' }, { status: 401 }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + if (!decoded) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + await connectDB(); + + // Await params for Next.js 15 compatibility + const { userId } = await params; + + const user = await User.findById(userId); + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // Get user's betting stats + const userBets = await Bet.find({ createdBy: userId }); + const userVotes = await Bet.aggregate([ + { $unwind: '$options' }, + { $unwind: '$options.votes' }, + { $match: { 'options.votes.userId': userId } }, + { $project: { stake: '$options.votes.stake' } } + ]); + + // Calculate stats + const totalBets = userBets.length; + const totalStaked = userVotes.reduce((sum, vote) => sum + vote.stake, 0); + const settledBets = userBets.filter(bet => bet.status === 'settled'); + const wins = settledBets.filter(bet => + bet.winningOption !== null && + bet.options[bet.winningOption].votes.some((vote: any) => vote.userId.toString() === userId) + ).length; + const losses = settledBets.length - wins; + + const stats = { + totalBets, + totalStaked, + wins, + losses, + winRate: settledBets.length > 0 ? (wins / settledBets.length * 100).toFixed(1) : '0.0' + }; + + return Response.json({ + userId: user._id.toString(), + username: user.username, + stats + }); + } catch (error) { + console.error('Error fetching user profile:', error); + return Response.json({ error: 'Failed to fetch user profile' }, { status: 500 }); + } } diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..e6d2e46 --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardHeader, CardContent, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Mail, AlertCircle, CheckCircle, ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + const { forgotPassword } = useAuth(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setSuccess(false); + setLoading(true); + + if (!email) { + setError("Please enter your email address"); + setLoading(false); + return; + } + + const success = await forgotPassword(email); + if (success) { + setSuccess(true); + } else { + setError("Failed to send reset email. Please try again."); + } + setLoading(false); + } + + return ( +
+ + + Forgot password +

+ Enter your email address and we'll send you a link to reset your password +

+
+ + {success ? ( +
+ + + + If an account with that email exists, we've sent you a password reset link. + Please check your email and follow the instructions. + + +
+

+ Didn't receive the email? Check your spam folder or try again. +

+ +
+
+ ) : ( +
+ {error && ( + + + {error} + + )} + +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ + + +
+ + + Back to login + +
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/groups/[groupId]/page.tsx b/src/app/groups/[groupId]/page.tsx index 906ef4a..a8bc2e8 100644 --- a/src/app/groups/[groupId]/page.tsx +++ b/src/app/groups/[groupId]/page.tsx @@ -1,3 +1,652 @@ -export default function GroupDetailsPage({ params }: { params: { groupId: string } }) { - return
Group Details Page for {params.groupId} (TODO: Show group info, bets)
; -} +"use client"; + +import { Card, CardHeader, CardContent, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import { useState, useEffect } from "react"; +import { Plus, Users, PoundSterling, Eye, Copy, Check } from "lucide-react"; +import { Group, Bet, BetResult } from "@/lib/types"; +import { BetCard } from "@/components/ui/BetCard"; +import { BetForm } from "@/components/ui/BetForm"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAutoRefresh } from "@/hooks/use-auto-refresh"; +import { AutoRefreshIndicator } from "@/components/ui/auto-refresh-indicator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export default function GroupDetailsPage({ params }: { params: Promise<{ groupId: string }> }) { + const router = useRouter(); + const { user, token, loading: authLoading } = useAuth(); + const [group, setGroup] = useState(null); + const [bets, setBets] = useState([]); + const [results, setResults] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [showCreateBet, setShowCreateBet] = useState(false); + const [showMembers, setShowMembers] = useState(false); + const [isModerator, setIsModerator] = useState(false); + const [groupId, setGroupId] = useState(null); + const [copied, setCopied] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); + const [alertType, setAlertType] = useState<"error" | "success">("error"); + const [settleDialogOpen, setSettleDialogOpen] = useState(false); + const [pendingSettleOption, setPendingSettleOption] = useState(null); + const [isFetching, setIsFetching] = useState(false); + + // Auto-refresh functionality + const { + refreshing, + autoRefreshPaused, + lastRefresh, + manualRefresh, + pauseAutoRefresh, + resetUserActivity + } = useAutoRefresh({ + enabled: !authLoading && !!user && !!token && !!groupId, + onRefresh: fetchGroupData + }); + + useEffect(() => { + // Await params for Next.js 15 compatibility + params.then(({ groupId: id }) => { + setGroupId(id); + // Trigger fetch immediately if auth is ready + if (!authLoading && user && token) { + fetchGroupData(); + } + }); + }, [params, authLoading, user, token]); + + // Fallback fetch if the above didn't trigger + useEffect(() => { + if (!authLoading && user && token && groupId && !group) { + fetchGroupData(); + } else if (!authLoading && !user) { + setLoading(false); + } + }, [groupId, user, token, authLoading, group]); + + async function fetchGroupData() { + if (!groupId) return; + + setIsFetching(true); + try { + // Fetch group details and bets in parallel + const [groupResponse, betsResponse] = await Promise.all([ + fetch(`/api/groups/${groupId}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }), + fetch(`/api/groups/${groupId}/bets`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + ]); + + // Handle group response + if (groupResponse.ok) { + const groupData = await groupResponse.json(); + setGroup(groupData.group); + + // Check if current user is moderator + setIsModerator( + groupData.group.ownerId === user?.id || + groupData.group.moderators.includes(user?.id) + ); + } else if (groupResponse.status === 404) { + // Group not found - might have been deleted + router.push('/groups'); + return; + } + + // Handle bets response + if (betsResponse.ok) { + const betsData = await betsResponse.json(); + setBets(betsData.bets); + + // Fetch results for settled bets in parallel + const settledBets = betsData.bets.filter((bet: Bet) => bet.status === 'settled'); + if (settledBets.length > 0) { + const resultPromises = settledBets.map(async (bet: Bet) => { + try { + const resultResponse = await fetch(`/api/bets/${bet.id}/payouts`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + if (resultResponse.ok) { + const resultData = await resultResponse.json(); + return { betId: bet.id, result: resultData.result }; + } + } catch (error) { + console.error(`Failed to fetch results for bet ${bet.id}:`, error); + } + return null; + }); + + const results = await Promise.all(resultPromises); + const newResults = new Map(); + results.forEach((item) => { + if (item) { + newResults.set(item.betId, item.result); + } + }); + setResults(newResults); + } + } + } catch (error) { + console.error('Error fetching group data:', error); + } finally { + setLoading(false); + setIsFetching(false); + } + } + + // Form handlers that pause auto-refresh + const handleOpenCreateBet = () => { + pauseAutoRefresh(); + setShowCreateBet(true); + }; + + const handleCloseCreateBet = () => { + setShowCreateBet(false); + resetUserActivity(); // Resume auto-refresh + }; + + const handleOpenMembers = () => { + pauseAutoRefresh(); + setShowMembers(true); + }; + + const handleCloseMembers = () => { + setShowMembers(false); + resetUserActivity(); // Resume auto-refresh + }; + + async function handleCreateBet(betData: { + title: string; + description: string; + options: string[]; + deadline: string; + minStake: number; + maxStake: number; + }) { + if (!groupId) return; + + try { + const response = await fetch('/api/bets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + ...betData, + groupId: groupId, + }), + }); + + if (response.ok) { + const data = await response.json(); + setBets([...bets, data.bet]); + setShowCreateBet(false); + resetUserActivity(); // Resume auto-refresh after successful creation + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to create bet'); + } + } catch (error) { + console.error('Error creating bet:', error); + showAlert('Failed to create bet'); + } + } + + async function handleVote(voteData: { optionId: string; stake: number }) { + try { + // Find the bet that contains this option + const bet = bets.find(b => b.options.some(opt => opt.id === voteData.optionId)); + if (!bet) return; + + const response = await fetch(`/api/bets/${bet.id}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(voteData), + }); + + if (response.ok) { + // Refresh the bets to show updated vote counts + fetchGroupData(); + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to place vote'); + } + } catch (error) { + console.error('Error placing vote:', error); + showAlert('Failed to place vote'); + } + } + + async function handleSettle(winningOptionId: string) { + try { + // Find the bet that contains this option + const bet = bets.find(b => b.options.some(opt => opt.id === winningOptionId)); + if (!bet) return; + + const response = await fetch(`/api/bets/${bet.id}/outcome`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ winningOptionId }), + }); + + if (response.ok) { + const data = await response.json(); + setResults(prev => new Map(prev).set(bet.id, data.result)); + // Refresh the bets to show updated status + fetchGroupData(); + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to settle bet'); + } + } catch (error) { + console.error('Error settling bet:', error); + showAlert('Failed to settle bet'); + } + } + + // Copy group code to clipboard + const handleCopyCode = async () => { + if (!group) return; + + try { + await navigator.clipboard.writeText(group.code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds + } catch (error) { + console.error('Failed to copy group code:', error); + } + }; + + // Show alert message + const showAlert = (message: string, type: "error" | "success" = "error") => { + setAlertMessage(message); + setAlertType(type); + setTimeout(() => setAlertMessage(""), 5000); // Auto-hide after 5 seconds + }; + + // Handle settle confirmation + const handleSettleConfirm = (winningOptionId: string) => { + setPendingSettleOption(winningOptionId); + setSettleDialogOpen(true); + }; + + const handleSettleConfirmed = async () => { + if (!pendingSettleOption) return; + + setSettleDialogOpen(false); + await handleSettle(pendingSettleOption); + setPendingSettleOption(null); + }; + + // Show loading state while auth is loading + if (authLoading) { + return ( +
+ + +
+
+
+
+ {[1, 2, 3].map((i) => ( + + +
+
+ +
+
+
+ ))} +
+
+ ); + } + + // Show login prompt if user is not authenticated + if (!user || !token) { + return ( +
+ + +

Please log in to view this group

+ +
+
+
+ ); + } + + if (loading && !refreshing && !group) { + return ( +
+ + +
+
+
+
+ {[1, 2, 3].map((i) => ( + + +
+
+ +
+
+
+ ))} +
+
+ ); + } + + if (!group) { + return ( +
+ + +

Group not found

+ +
+
+
+ ); + } + + return ( +
+ {/* Alert Messages */} + {alertMessage && ( + + {alertMessage} + + )} + + {/* Group Info Card */} + + +
+
+ {group.name} +

{group.description}

+ +
+
+
+ + {group.members.length} member{group.members.length !== 1 ? 's' : ''} +
+
+ + £{group.minStake}-£{group.maxStake} +
+
+
+
+ +
+
+
+ Group Code: + {group.code} + +
+ +
+
+ + {isModerator && ( + + )} +
+
+
+
+ + {/* View Members Dialog */} + + + + Group Members + +
+ {group.members.map((member: any) => ( +
+ {member.username} + {member.email} +
+ ))} +
+
+
+ + {/* Create Bet Dialog */} + {showCreateBet && ( + + )} + + {/* Bets Section */} +
+

Active Bets

+ {loading && !group ? ( +
+ {[1, 2, 3].map((i) => ( + + +
+
+ +
+
+
+ ))} +
+ ) : bets.length === 0 ? ( + + +

No active bets

+
+
+ ) : ( + bets.map((bet) => ( + + )) + )} +
+ + {/* Bets Table Section */} +
+

All Bets

+ + +
+ + + + + + + + + {isModerator && } + + + + {bets.length === 0 ? ( + + + + ) : ( + bets.map((bet) => { + const totalVotes = bet.options.reduce((total, option) => total + option.votes.length, 0); + const totalPool = bet.options.reduce((total, option) => + total + option.votes.reduce((sum, vote) => sum + vote.stake, 0), 0 + ); + const isExpired = new Date(bet.deadline) < new Date(); + const canSettle = bet.status === 'closed' && isModerator; + + return ( + + + + + + + {isModerator && ( + + )} + + ); + }) + )} + +
TitleStatusDeadlineTotal VotesTotal PoolActions
+ No bets found +
+
+
{bet.title}
+
{bet.description}
+
+
+ + {bet.status === 'settled' ? 'Settled' : + bet.status === 'closed' ? 'Closed' : + isExpired ? 'Expired' : 'Active'} + + + {new Date(bet.deadline).toLocaleDateString()} + {totalVotes}£{totalPool.toFixed(2)} + {canSettle && ( + + )} +
+
+
+
+
+ + {/* Settle Confirmation Dialog */} + + + + Settle Bet + + Are you sure you want to settle this bet? This action cannot be undone and will calculate payouts for all participants. + + + + Cancel + + Settle Bet + + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index f146e1b..06c9a46 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -1,3 +1,449 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardContent, CardTitle } from "@/components/ui/card"; +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Trash2, LogIn, Copy, Check, Users } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { Group } from "@/lib/types"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + export default function GroupsPage() { - return
Groups List Page (TODO: List groups)
; -} + const { user, token, loading: authLoading } = useAuth(); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [joinDialogOpen, setJoinDialogOpen] = useState(false); + const [newGroup, setNewGroup] = useState({ + name: "", + description: "", + minStake: 1, + maxStake: 100, + }); + const [joinCode, setJoinCode] = useState(""); + const [copiedCode, setCopiedCode] = useState(null); + const [alertMessage, setAlertMessage] = useState(""); + const [alertType, setAlertType] = useState<"error" | "success">("error"); + const [leaveDialogOpen, setLeaveDialogOpen] = useState(false); + const [pendingLeaveGroupId, setPendingLeaveGroupId] = useState(null); + + useEffect(() => { + if (!authLoading) { + if (user && token) { + fetchGroups(); + } else { + setLoading(false); + } + } + }, [user, token, authLoading]); + + async function fetchGroups() { + try { + const response = await fetch('/api/groups', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + if (data.groups) { + setGroups(data.groups); + } + } else if (response.status === 401) { + // Token expired or invalid + console.error('Authentication failed'); + } + } catch (error) { + console.error('Error fetching groups:', error); + } finally { + setLoading(false); + } + } + + function handleInputChange(e: React.ChangeEvent) { + setNewGroup({ ...newGroup, [e.target.name]: e.target.value }); + } + + async function handleCreateGroup(e: React.FormEvent) { + e.preventDefault(); + try { + const response = await fetch('/api/groups', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(newGroup), + }); + + if (response.ok) { + const data = await response.json(); + setGroups([...groups, data.group]); + setDialogOpen(false); + setNewGroup({ name: "", description: "", minStake: 1, maxStake: 100 }); + showAlert('Successfully created group', 'success'); + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to create group'); + } + } catch (error) { + console.error('Error creating group:', error); + showAlert('Failed to create group'); + } + } + + async function handleJoinGroup(e: React.FormEvent) { + e.preventDefault(); + try { + const response = await fetch('/api/groups/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ code: joinCode }), + }); + + if (response.ok) { + const data = await response.json(); + setGroups([...groups, data.group]); + setJoinDialogOpen(false); + setJoinCode(""); + showAlert('Successfully joined group!', 'success'); + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to join group'); + } + } catch (error) { + console.error('Error joining group:', error); + showAlert('Failed to join group'); + } + } + + async function handleLeaveGroup(groupId: string) { + try { + const response = await fetch(`/api/groups/${groupId}/members/${user?.id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setGroups(groups.filter(group => group.id !== groupId)); + showAlert('Successfully left group', 'success'); + } else { + const error = await response.json(); + showAlert(error.error || 'Failed to leave group'); + } + } catch (error) { + console.error('Error leaving group:', error); + showAlert('Failed to leave group'); + } + } + + function copyToClipboard(code: string) { + navigator.clipboard.writeText(code); + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + } + + // Show loading state while auth is loading + if (authLoading) { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( + + +
+
+
+
+ +
+
+
+
+
+
+ ))} +
+
+ ); + } + + // Show login prompt if user is not authenticated + if (!user || !token) { + return ( +
+ + +
+ +
+ Login Required +

+ Please log in to view and manage your groups. +

+
+ + + + +
+
+ ); + } + + // Show loading state while fetching groups + if (loading) { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( + + +
+
+
+
+ +
+
+
+
+
+
+ ))} +
+
+ ); + } + + // Show alert message + const showAlert = (message: string, type: "error" | "success" = "error") => { + setAlertMessage(message); + setAlertType(type); + setTimeout(() => setAlertMessage(""), 5000); // Auto-hide after 5 seconds + }; + + // Handle leave group confirmation + const handleLeaveGroupConfirm = (groupId: string) => { + setPendingLeaveGroupId(groupId); + setLeaveDialogOpen(true); + }; + + const handleLeaveGroupConfirmed = async () => { + if (!pendingLeaveGroupId) return; + + setLeaveDialogOpen(false); + await handleLeaveGroup(pendingLeaveGroupId); + setPendingLeaveGroupId(null); + }; + + return ( +
+ {/* Alert Messages */} + {alertMessage && ( + + {alertMessage} + + )} + + {/* Groups grid section */} +
+ {groups.length === 0 ? ( + + +
+ +
+ No Groups Yet +

+ You haven't joined any groups yet. Create a new group or join an existing one to get started. +

+
+
+ ) : ( + groups.map((group) => ( + + + + {group.name} + +

+ {group.description} +

+
+ + {group.members.length} member{group.members.length !== 1 ? "s" : ""} + +
+ Code: {group.code} + +
+
+
+ + + + +
+ )) + )} +
+ + {/* Controls & Actions Section */} +
+ + + Group Controls & Actions +

+ Create a new group or manage your memberships. +

+
+ + + + + + + + Create a New Group + +
+
+ + +
+
+ +