From 607e4751e8ca7583d7f6693b0308f44a51a9df67 Mon Sep 17 00:00:00 2001 From: Mjsims42 Date: Tue, 21 Oct 2025 11:55:32 -0400 Subject: [PATCH 01/16] DockerConfig --- .../Project.Api/Controllers/UserController.cs | 2 +- Project.App/Project.Api/Program.cs | 4 ++ Project.App/Project.Client/Dockerfile | 51 +++++++------------ Project.App/Project.Client/jsconfig.json | 5 +- Project.App/Project.Client/nginx.conf | 1 - Project.App/Project.Client/package.json | 1 + .../Tests.Controllers/UserControllerTest.cs | 38 ++++++++++---- .../UserControllerUnitTest.cs | 38 ++++++++++---- compose.yml | 25 ++------- 9 files changed, 88 insertions(+), 77 deletions(-) delete mode 100644 Project.App/Project.Client/nginx.conf diff --git a/Project.App/Project.Api/Controllers/UserController.cs b/Project.App/Project.Api/Controllers/UserController.cs index 330d90f..a7b0d3a 100644 --- a/Project.App/Project.Api/Controllers/UserController.cs +++ b/Project.App/Project.Api/Controllers/UserController.cs @@ -80,4 +80,4 @@ public async Task DeleteUser(Guid id) return NoContent(); } } -} \ No newline at end of file +} diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index d277157..9a7bd68 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -22,6 +22,10 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + // Use environment variable for port + var port = Environment.GetEnvironmentVariable("WEBSITES_PORT") ?? "5131"; + builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + builder.Configuration.AddJsonFile( "adminsetting.json", optional: true, diff --git a/Project.App/Project.Client/Dockerfile b/Project.App/Project.Client/Dockerfile index b79be7f..edd27bc 100644 --- a/Project.App/Project.Client/Dockerfile +++ b/Project.App/Project.Client/Dockerfile @@ -1,46 +1,29 @@ +# Stage 1: Build FROM node:24-alpine AS build -# set working directory -WORKDIR /src +# Set working directory inside container +WORKDIR /app -# install dependencies -COPY package.json package-lock.json ./ +# Copy package files and install dependencies +COPY package*.json ./ RUN npm ci -# copy frontend code -COPY . . +# Copy all source code (including src/app) +COPY . . -# build +# Build the Next.js app RUN npm run build -FROM nginx:alpine AS run +# Stage 2: Run +FROM node:24-alpine AS run -# copy nginx config (if anything other than the default is required) -# COPY nginx.conf /etc/nginx/conf.d/default.conf +WORKDIR /app -# copy built files from build -COPY --from=build /src/out /usr/share/nginx/html +# Copy built files and dependencies from build stage +COPY --from=build /app ./ -# according to richard this does nothing :/ -EXPOSE 80 +# Expose default Next.js port +EXPOSE 3000 -# run (in the foreground)! hopefully. -CMD ["nginx", "-g", "daemon off;"] - -# --- run frontend from node server (if SSR is required) --- -# FROM node:24-alpine AS run - -# # set working directory -# WORKDIR /app - -# # copy built files from build -# COPY --from=build /src/.next ./.next -# # copy static files -# COPY --from=build /src/public ./public - -# # install dependencies (without dev deps) -# COPY package.json package-lock.json ./ -# RUN npm ci --omit=dev - -# # run! (on node server) -# CMD ["npm", "run", "start"] +# Start the Next.js server +CMD ["npm", "start"] diff --git a/Project.App/Project.Client/jsconfig.json b/Project.App/Project.Client/jsconfig.json index b8d6842..85e12e4 100644 --- a/Project.App/Project.Client/jsconfig.json +++ b/Project.App/Project.Client/jsconfig.json @@ -2,6 +2,9 @@ "compilerOptions": { "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": [ + "./node_modules/@types" + ] } } diff --git a/Project.App/Project.Client/nginx.conf b/Project.App/Project.Client/nginx.conf deleted file mode 100644 index b4f465c..0000000 --- a/Project.App/Project.Client/nginx.conf +++ /dev/null @@ -1 +0,0 @@ -# add nginx config here if necessary diff --git a/Project.App/Project.Client/package.json b/Project.App/Project.Client/package.json index 2b9af80..dcbf5e3 100644 --- a/Project.App/Project.Client/package.json +++ b/Project.App/Project.Client/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "export": "next export", "lint": "oxlint . -D correctness --ignore-path .gitignore", "lint:fix": "oxlint . --fix -D correctness --ignore-path .gitignore", "format": "prettier --write .", diff --git a/Project.App/Project.Test/Tests.Controllers/UserControllerTest.cs b/Project.App/Project.Test/Tests.Controllers/UserControllerTest.cs index 70c4c12..746be92 100644 --- a/Project.App/Project.Test/Tests.Controllers/UserControllerTest.cs +++ b/Project.App/Project.Test/Tests.Controllers/UserControllerTest.cs @@ -1,12 +1,12 @@ -using Xunit; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using Moq; using Project.Api.Controllers; using Project.Api.Models; using Project.Api.Repositories.Interface; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; +using Xunit; namespace Project.Api.Tests { @@ -27,8 +27,18 @@ public async Task GetAllUsers_ReturnsOkResult_WithUsers() // Arrange var users = new List { - new User { Id = Guid.NewGuid(), Name = "Sneha", Email = "sneha@example.com" }, - new User { Id = Guid.NewGuid(), Name = "Leo", Email = "leo@example.com" } + new User + { + Id = Guid.NewGuid(), + Name = "Sneha", + Email = "sneha@example.com", + }, + new User + { + Id = Guid.NewGuid(), + Name = "Leo", + Email = "leo@example.com", + }, }; _mockRepo.Setup(repo => repo.GetAllAsync()).ReturnsAsync(users); @@ -46,7 +56,12 @@ public async Task GetUserById_ReturnsOk_WhenUserExists() { // Arrange var userId = Guid.NewGuid(); - var user = new User { Id = userId, Name = "Sneha", Email = "sneha@example.com" }; + var user = new User + { + Id = userId, + Name = "Sneha", + Email = "sneha@example.com", + }; _mockRepo.Setup(r => r.GetByIdAsync(userId)).ReturnsAsync(user); // Act @@ -76,7 +91,12 @@ public async Task GetUserById_ReturnsNotFound_WhenUserDoesNotExist() public async Task CreateUser_ReturnsCreatedAtAction() { // Arrange - var user = new User { Id = Guid.NewGuid(), Name = "Sneha", Email = "sneha@example.com" }; + var user = new User + { + Id = Guid.NewGuid(), + Name = "Sneha", + Email = "sneha@example.com", + }; _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); // Act diff --git a/Project.App/Project.Test/Tests.Controllers/UserControllerUnitTest.cs b/Project.App/Project.Test/Tests.Controllers/UserControllerUnitTest.cs index 0092e64..c3b194e 100644 --- a/Project.App/Project.Test/Tests.Controllers/UserControllerUnitTest.cs +++ b/Project.App/Project.Test/Tests.Controllers/UserControllerUnitTest.cs @@ -1,12 +1,12 @@ -using Xunit; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using Moq; using Project.Api.Controllers; using Project.Api.Models; using Project.Api.Repositories.Interface; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; +using Xunit; namespace Project.Tests.Controllers { @@ -27,8 +27,18 @@ public async Task GetAllUsers_ReturnsOkResult_WithListOfUsers() // Arrange var users = new List { - new User { Id = Guid.NewGuid(), Name = "Sneha", Email = "sneha@example.com" }, - new User { Id = Guid.NewGuid(), Name = "Leo", Email = "leo@example.com" } + new User + { + Id = Guid.NewGuid(), + Name = "Sneha", + Email = "sneha@example.com", + }, + new User + { + Id = Guid.NewGuid(), + Name = "Leo", + Email = "leo@example.com", + }, }; _mockRepo.Setup(repo => repo.GetAllAsync()).ReturnsAsync(users); @@ -46,7 +56,12 @@ public async Task GetUserById_ReturnsOk_WhenUserExists() { // Arrange var userId = Guid.NewGuid(); - var user = new User { Id = userId, Name = "Sneha", Email = "sneha@example.com" }; + var user = new User + { + Id = userId, + Name = "Sneha", + Email = "sneha@example.com", + }; _mockRepo.Setup(repo => repo.GetByIdAsync(userId)).ReturnsAsync(user); // Act @@ -76,7 +91,12 @@ public async Task GetUserById_ReturnsNotFound_WhenUserDoesNotExist() public async Task CreateUser_ReturnsCreatedAtAction() { // Arrange - var user = new User { Id = Guid.NewGuid(), Name = "Sneha", Email = "sneha@example.com" }; + var user = new User + { + Id = Guid.NewGuid(), + Name = "Sneha", + Email = "sneha@example.com", + }; _mockRepo.Setup(repo => repo.AddAsync(It.IsAny())).Returns(Task.CompletedTask); // Act diff --git a/compose.yml b/compose.yml index 870b679..6901d77 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: build: context: ./Project.App/Project.Client ports: - - 8080:80 + - 8080:3000 depends_on: - backend @@ -13,24 +13,5 @@ services: context: ./Project.App/Project.Api environment: # binds to ConnectionStrings:DefaultConnection - - ConnectionStrings__DefaultConnection=Server=db;Database=DevitoDB;User Id=SA;Password=ThisIsTheBestPassword!!1!;TrustServerCertificate=True; - ports: - - 5131:5131 - depends_on: - - db - - db: - image: mcr.microsoft.com/mssql/server:2022-latest - restart: always - environment: - - ACCEPT_EULA=Y - - SA_PASSWORD=ThisIsTheBestPassword!!1! - - MSSQL_PID=Developer # set to developer cuz we're developing :) - - MSSQL_DATABASE=DevitoDB - ports: - - 1433:1433 - volumes: - - db_data:/var/opt/mssql - -volumes: - db_data: # persist database data + - Server=tcp:250908casinolobby.database.windows.net,1433;Initial Catalog=CasinoLobby;Persist Security Info=False;User ID=bigboss250908;Password=headHoncho!pa$$word;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30; + - WEBSITES_PORT=5131 From 3cb65570b862e5767cbab67772cb3d8c55145111 Mon Sep 17 00:00:00 2001 From: ArielFSoler Date: Thu, 23 Oct 2025 06:43:30 -0700 Subject: [PATCH 02/16] init --- Project.App/Project.Client/package-lock.json | 102 ++++++++++++++++++- Project.App/Project.Client/package.json | 4 +- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/Project.App/Project.Client/package-lock.json b/Project.App/Project.Client/package-lock.json index 7bed0b4..e6afa5f 100644 --- a/Project.App/Project.Client/package-lock.json +++ b/Project.App/Project.Client/package-lock.json @@ -18,11 +18,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "autoprefixer": "^10.4.21", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "oxlint": "^1.23.0", + "postcss": "^8.5.6", "prettier": "^3.6.2", - "tailwindcss": "^4" + "tailwindcss": "^4.1.15" } }, "node_modules/@adobe/css-tools": { @@ -97,6 +99,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -680,6 +683,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -703,6 +707,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1981,6 +1986,13 @@ "tailwindcss": "4.1.14" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz", @@ -2241,12 +2253,20 @@ "tailwindcss": "4.1.14" } }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2893,6 +2913,44 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -3052,6 +3110,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3681,6 +3740,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4720,6 +4793,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5351,6 +5425,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -5642,6 +5726,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5651,6 +5736,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -5727,6 +5819,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5736,6 +5829,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6229,9 +6323,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.14", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", - "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", + "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", "dev": true, "license": "MIT" }, diff --git a/Project.App/Project.Client/package.json b/Project.App/Project.Client/package.json index 2b9af80..eac3d15 100644 --- a/Project.App/Project.Client/package.json +++ b/Project.App/Project.Client/package.json @@ -25,10 +25,12 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "autoprefixer": "^10.4.21", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "oxlint": "^1.23.0", + "postcss": "^8.5.6", "prettier": "^3.6.2", - "tailwindcss": "^4" + "tailwindcss": "^4.1.15" } } From e20a14ad70703b1b82bfebdedb68027dad27e3b2 Mon Sep 17 00:00:00 2001 From: ArielFSoler Date: Thu, 23 Oct 2025 08:23:58 -0700 Subject: [PATCH 03/16] muhiddin's branch --- .../Project.Api/Controllers/AuthController.cs | 70 +- .../Project.Api/Controllers/HandController.cs | 2 + .../Project.Api/Controllers/RoomController.cs | 25 + .../Project.Api/Controllers/UserController.cs | 78 +-- ...20251022135651_removedCardJSON.Designer.cs | 239 ------- .../20251022135651_removedCardJSON.cs | 148 ---- .../Migrations/AppDbContextModelSnapshot.cs | 236 ------- Project.App/Project.Api/Program.cs | 199 +++--- .../Repositories/Interface/IUserRepository.cs | 2 - .../Repositories/UserRepository.cs | 7 - .../Project.Api/Services/BlackjackService.cs | 469 +++++++++++-- .../Project.Api/Services/DeckApiService.cs | 35 +- .../Services/Interface/IDeckApiService.cs | 12 + .../Services/Interface/IRoomSSEService.cs | 7 +- .../Project.Api/Services/RoomSSEService.cs | 44 +- .../Project.Api/Services/RoomService.cs | 171 ++++- .../Services/SSEShutdownService.cs | 32 + .../Project.Api/Services/UserService.cs | 2 +- .../Project.Api/appsettings.Example.json | 29 + Project.App/Project.Api/start-db.sh | 64 ++ Project.App/Project.Client/next.config.mjs | 9 + Project.App/Project.Client/package-lock.json | 102 +-- Project.App/Project.Client/package.json | 4 +- .../src/app/components/AddCreditsModal.js | 14 +- .../src/app/components/CreateGameForm.js | 179 +++-- .../src/app/components/NavBar.js | 5 +- .../src/app/game/[id]/GameClient.jsx | 637 ++++++++++++++++++ .../Project.Client/src/app/game/[id]/page.js | 18 +- .../src/app/player/[id]/PlayerClient.jsx | 117 ++-- .../src/app/player/[id]/page.js | 9 +- .../src/app/rooms/RoomsClient.jsx | 276 +++++++- .../Project.Client/src/app/rooms/page.js | 13 +- .../Controllers/UserControllerTest.cs | 35 +- .../Controllers/UserControllerUnitTest.cs | 38 +- .../Services/BlackjackServiceTest.cs | 18 + .../Services/DeckApiServiceTest.cs | 29 +- .../Services/RoomSSEServiceTests.cs | 9 +- .../Project.Test/Services/RoomServiceTest.cs | 34 +- .../Project.Test/Services/UserServiceTest.cs | 15 +- 39 files changed, 2195 insertions(+), 1237 deletions(-) delete mode 100644 Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.Designer.cs delete mode 100644 Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.cs delete mode 100644 Project.App/Project.Api/Migrations/AppDbContextModelSnapshot.cs create mode 100644 Project.App/Project.Api/Services/SSEShutdownService.cs create mode 100644 Project.App/Project.Api/appsettings.Example.json create mode 100644 Project.App/Project.Api/start-db.sh create mode 100644 Project.App/Project.Client/src/app/game/[id]/GameClient.jsx diff --git a/Project.App/Project.Api/Controllers/AuthController.cs b/Project.App/Project.Api/Controllers/AuthController.cs index 0bfa8fd..11727b0 100644 --- a/Project.App/Project.Api/Controllers/AuthController.cs +++ b/Project.App/Project.Api/Controllers/AuthController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Project.Api.Services.Interface; namespace Project.Api.Controllers; @@ -10,15 +11,26 @@ namespace Project.Api.Controllers; [Route("auth")] public class AuthController : ControllerBase { + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public AuthController(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + // GET /auth/login [HttpGet("login")] [AllowAnonymous] public IActionResult Login([FromQuery] string? returnUrl = null) { - Console.WriteLine($"[AUTH] Login called with returnUrl: {returnUrl}"); + _logger.LogInformation("[AUTH] Login called with returnUrl: {ReturnUrl}", returnUrl); - // Allow frontend URLs (localhost:3000 or your production frontend) - var allowedOrigins = new[] { "http://localhost:3000", "https://localhost:3000" }; + // Allow frontend URLs from configuration + var allowedOrigins = + _configuration.GetSection("CorsSettings:AllowedOrigins").Get() + ?? Array.Empty(); var safe = "/swagger"; // default fallback if (!string.IsNullOrEmpty(returnUrl)) @@ -30,28 +42,31 @@ public IActionResult Login([FromQuery] string? returnUrl = null) ) { safe = returnUrl; - Console.WriteLine($"[AUTH] returnUrl accepted: {safe}"); + _logger.LogInformation("[AUTH] returnUrl accepted: {Safe}", safe); } else { - Console.WriteLine($"[AUTH] returnUrl rejected, using fallback: {safe}"); + _logger.LogWarning("[AUTH] returnUrl rejected, using fallback: {Safe}", safe); } } else { - Console.WriteLine($"[AUTH] No returnUrl provided, using fallback: {safe}"); + _logger.LogInformation("[AUTH] No returnUrl provided, using fallback: {Safe}", safe); } if (User.Identity?.IsAuthenticated == true) { - Console.WriteLine($"[AUTH] User already authenticated, redirecting to: {safe}"); + _logger.LogInformation( + "[AUTH] User already authenticated, redirecting to: {Safe}", + safe + ); return Redirect(safe); } var props = new AuthenticationProperties { RedirectUri = safe }; props.SetParameter("prompt", "select_account"); - Console.WriteLine($"[AUTH] Challenging with RedirectUri: {safe}"); + _logger.LogInformation("[AUTH] Challenging with RedirectUri: {Safe}", safe); return Challenge(props, GoogleDefaults.AuthenticationScheme); } @@ -78,4 +93,43 @@ public IActionResult Me() } ); } + + // POST /auth/debug/create-user - Temporary debug endpoint + [HttpPost("debug/create-user")] + [AllowAnonymous] + public async Task DebugCreateUser( + [FromBody] CreateUserDebugRequest request, + [FromServices] IUserService userService + ) + { + _logger.LogInformation("Debug: Creating/finding user with email {Email}", request.Email); + + try + { + // Try to find existing user + var existingUser = await userService.GetByEmailAsync(request.Email); + if (existingUser != null) + { + _logger.LogInformation("Debug: User already exists with ID {UserId}", existingUser.Id); + return Ok(new { message = "User already exists", user = existingUser }); + } + + // Create new user + var user = await userService.UpsertGoogleUserByEmailAsync( + request.Email, + request.Name ?? request.Email, + null + ); + + _logger.LogInformation("Debug: User created with ID {UserId}", user.Id); + return Ok(new { message = "User created", user }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Debug: Error creating user"); + return StatusCode(500, new { error = ex.Message }); + } + } } + +public record CreateUserDebugRequest(string Email, string? Name); diff --git a/Project.App/Project.Api/Controllers/HandController.cs b/Project.App/Project.Api/Controllers/HandController.cs index 61935dd..1f0cf9e 100644 --- a/Project.App/Project.Api/Controllers/HandController.cs +++ b/Project.App/Project.Api/Controllers/HandController.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Project.Api.DTOs; using Project.Api.Models; @@ -8,6 +9,7 @@ namespace Project.Api.Controllers { [ApiController] [Route("api/rooms/{roomId}/hands")] + [Authorize] // Require authentication for all hand endpoints public class HandController : ControllerBase { private readonly ILogger _logger; diff --git a/Project.App/Project.Api/Controllers/RoomController.cs b/Project.App/Project.Api/Controllers/RoomController.cs index 67d2d6c..2946660 100644 --- a/Project.App/Project.Api/Controllers/RoomController.cs +++ b/Project.App/Project.Api/Controllers/RoomController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Project.Api.DTOs; +using Project.Api.Repositories.Interface; using Project.Api.Services.Interface; using Project.Api.Utilities; @@ -9,6 +10,7 @@ namespace Project.Api.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] // Require authentication for all room endpoints public class RoomController( IRoomService roomService, IRoomSSEService roomSSEService, @@ -205,6 +207,28 @@ [FromBody] LeaveRoomRequest request return Ok(room); } + // GET: api/room/{roomId}/players + [HttpGet("{roomId}/players")] + public async Task GetRoomPlayers( + Guid roomId, + [FromServices] IRoomPlayerRepository roomPlayerRepository + ) + { + var players = await roomPlayerRepository.GetByRoomIdAsync(roomId); + var playerDtos = players.Select(p => new + { + id = p.Id, + userId = p.UserId, + userName = p.User?.Name ?? "Unknown", + userEmail = p.User?.Email ?? "", + role = p.Role.ToString(), + status = p.Status.ToString(), + balance = p.Balance, + balanceDelta = p.BalanceDelta, + }); + return Ok(playerDtos); + } + #region SSE private readonly IRoomSSEService _roomSSEService = roomSSEService; @@ -214,6 +238,7 @@ [FromBody] LeaveRoomRequest request /// /// /// + [AllowAnonymous] [HttpGet("{roomId}/events")] public async Task GetRoomEvents(Guid roomId) { diff --git a/Project.App/Project.Api/Controllers/UserController.cs b/Project.App/Project.Api/Controllers/UserController.cs index c01971b..11af699 100644 --- a/Project.App/Project.Api/Controllers/UserController.cs +++ b/Project.App/Project.Api/Controllers/UserController.cs @@ -8,20 +8,21 @@ namespace Project.Api.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize] // Require authentication for all endpoints public class UserController : ControllerBase { - private readonly IUserService _userService; + private readonly IUserRepository _userRepository; - public UserController(IUserService userService) + public UserController(IUserRepository userRepository) { - _userService = userService; + _userRepository = userRepository; } // GET: api/user [HttpGet] public async Task>> GetAllUsers() { - var users = await _userService.GetAllUsersAsync(); + var users = await _userRepository.GetAllAsync(); return Ok(users); } @@ -29,7 +30,7 @@ public async Task>> GetAllUsers() [HttpGet("{id}")] public async Task> GetUserById(Guid id) { - var user = await _userService.GetUserByIdAsync(id); + var user = await _userRepository.GetByIdAsync(id); if (user == null) return NotFound($"User with ID {id} not found."); return Ok(user); @@ -42,7 +43,7 @@ public async Task> CreateUser([FromBody] User user) if (!ModelState.IsValid) return BadRequest(ModelState); - await _userService.CreateUserAsync(user); + await _userRepository.AddAsync(user); return CreatedAtAction(nameof(GetUserById), new { id = user.Id }, user); } @@ -53,7 +54,7 @@ public async Task UpdateUser(Guid id, [FromBody] User user) if (id != user.Id) return BadRequest("User ID mismatch."); - var existingUser = await _userService.GetUserByIdAsync(id); + var existingUser = await _userRepository.GetByIdAsync(id); if (existingUser == null) return NotFound($"User with ID {id} not found."); @@ -62,34 +63,7 @@ public async Task UpdateUser(Guid id, [FromBody] User user) existingUser.Email = user.Email; existingUser.Balance = user.Balance; - await _userService.UpdateUserAsync(id, existingUser); - return NoContent(); - } - - // PATCH: api/user/{id} - [HttpPatch("{id}")] - [Authorize] - public async Task UpdateBalance( - Guid id, - [FromBody] UpdateBalanceRequest body - ) - { - if (body is null) - return BadRequest("Missing body."); - - // only allow the logged-in user to change their own balance - var emailClaim = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; - if (string.IsNullOrWhiteSpace(emailClaim)) - return Unauthorized(); - - var me = await _userService.GetUserByEmailAsync(emailClaim); - if (me is null) - return Unauthorized(); - - if (me.Id != id) - return Forbid(); // 403 if trying to edit someone else - - await _userService.UpdateUserBalanceAsync(id, body.Balance); + await _userRepository.UpdateAsync(existingUser); return NoContent(); } @@ -97,11 +71,11 @@ [FromBody] UpdateBalanceRequest body [HttpDelete("{id}")] public async Task DeleteUser(Guid id) { - var existingUser = await _userService.GetUserByIdAsync(id); + var existingUser = await _userRepository.GetByIdAsync(id); if (existingUser == null) return NotFound($"User with ID {id} not found."); - await _userService.DeleteUserAsync(id); + await _userRepository.DeleteAsync(id); return NoContent(); } @@ -114,7 +88,7 @@ public async Task Me([FromServices] IUserService users) if (email is null) return Unauthorized(); - var u = await users.GetUserByEmailAsync(email.Value); + var u = await users.GetByEmailAsync(email.Value); if (u is null) return NotFound(); //temporary user dto since we dont have one made @@ -129,7 +103,33 @@ public async Task Me([FromServices] IUserService users) } ); } + + // PATCH: api/user/{id}/balance + [HttpPatch("{id}/balance")] + public async Task UpdateBalance(Guid id, [FromBody] UpdateBalanceRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var existingUser = await _userRepository.GetByIdAsync(id); + if (existingUser == null) + return NotFound($"User with ID {id} not found."); + + // Add the credits to the current balance + existingUser.Balance += request.Amount; + + // Ensure balance doesn't go negative + if (existingUser.Balance < 0) + existingUser.Balance = 0; + + await _userRepository.UpdateAsync(existingUser); + + return Ok(new { balance = existingUser.Balance }); + } } - public record UpdateBalanceRequest(double Balance); + public class UpdateBalanceRequest + { + public double Amount { get; set; } + } } diff --git a/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.Designer.cs b/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.Designer.cs deleted file mode 100644 index c98789c..0000000 --- a/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.Designer.cs +++ /dev/null @@ -1,239 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Project.Api.Data; - -#nullable disable - -namespace Project.Api.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20251022135651_removedCardJSON")] - partial class removedCardJSON - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Project.Api.Models.Hand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Bet") - .HasColumnType("int"); - - b.Property("Order") - .HasColumnType("int"); - - b.Property("RoomPlayerId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("RoomPlayerId"); - - b.ToTable("Hands"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("DeckId") - .HasColumnType("int"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("EndedAt") - .HasColumnType("datetime2"); - - b.Property("GameConfig") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("GameMode") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("GameState") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("HostId") - .HasColumnType("uniqueidentifier"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("IsPublic") - .HasColumnType("bit"); - - b.Property("MaxPlayers") - .HasColumnType("int"); - - b.Property("MinPlayers") - .HasColumnType("int"); - - b.Property("Round") - .HasColumnType("int"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("rowversion"); - - b.Property("StartedAt") - .HasColumnType("datetime2"); - - b.Property("State") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("HostId"); - - b.ToTable("Rooms"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Balance") - .HasColumnType("bigint"); - - b.Property("BalanceDelta") - .HasColumnType("bigint"); - - b.Property("Role") - .HasColumnType("int"); - - b.Property("RoomId") - .HasColumnType("uniqueidentifier"); - - b.Property("Status") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("RoomId"); - - b.HasIndex("UserId"); - - b.ToTable("RoomPlayers"); - }); - - modelBuilder.Entity("Project.Api.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("AvatarUrl") - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("Balance") - .HasColumnType("float"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Project.Api.Models.Hand", b => - { - b.HasOne("Project.Api.Models.RoomPlayer", "RoomPlayer") - .WithMany("Hands") - .HasForeignKey("RoomPlayerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("RoomPlayer"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.HasOne("Project.Api.Models.User", "Host") - .WithMany() - .HasForeignKey("HostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Host"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.HasOne("Project.Api.Models.Room", "Room") - .WithMany("RoomPlayers") - .HasForeignKey("RoomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Project.Api.Models.User", "User") - .WithMany("RoomPlayers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Room"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.Navigation("RoomPlayers"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.Navigation("Hands"); - }); - - modelBuilder.Entity("Project.Api.Models.User", b => - { - b.Navigation("RoomPlayers"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.cs b/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.cs deleted file mode 100644 index 8ecd052..0000000 --- a/Project.App/Project.Api/Migrations/20251022135651_removedCardJSON.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Project.Api.Migrations -{ - /// - public partial class removedCardJSON : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - Balance = table.Column(type: "float", nullable: false), - AvatarUrl = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Rooms", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - HostId = table.Column(type: "uniqueidentifier", nullable: false), - IsPublic = table.Column(type: "bit", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false), - StartedAt = table.Column(type: "datetime2", nullable: true), - EndedAt = table.Column(type: "datetime2", nullable: true), - GameMode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), - GameConfig = table.Column(type: "nvarchar(max)", nullable: false), - GameState = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - MaxPlayers = table.Column(type: "int", nullable: false), - MinPlayers = table.Column(type: "int", nullable: false), - DeckId = table.Column(type: "int", nullable: false), - Round = table.Column(type: "int", nullable: false), - State = table.Column(type: "nvarchar(max)", nullable: false), - RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Rooms", x => x.Id); - table.ForeignKey( - name: "FK_Rooms_Users_HostId", - column: x => x.HostId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "RoomPlayers", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - RoomId = table.Column(type: "uniqueidentifier", nullable: false), - UserId = table.Column(type: "uniqueidentifier", nullable: false), - Role = table.Column(type: "int", nullable: false), - Status = table.Column(type: "int", nullable: false), - Balance = table.Column(type: "bigint", nullable: false), - BalanceDelta = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RoomPlayers", x => x.Id); - table.ForeignKey( - name: "FK_RoomPlayers_Rooms_RoomId", - column: x => x.RoomId, - principalTable: "Rooms", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_RoomPlayers_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "Hands", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - RoomPlayerId = table.Column(type: "uniqueidentifier", nullable: false), - Order = table.Column(type: "int", nullable: false), - Bet = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Hands", x => x.Id); - table.ForeignKey( - name: "FK_Hands_RoomPlayers_RoomPlayerId", - column: x => x.RoomPlayerId, - principalTable: "RoomPlayers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Hands_RoomPlayerId", - table: "Hands", - column: "RoomPlayerId"); - - migrationBuilder.CreateIndex( - name: "IX_RoomPlayers_RoomId", - table: "RoomPlayers", - column: "RoomId"); - - migrationBuilder.CreateIndex( - name: "IX_RoomPlayers_UserId", - table: "RoomPlayers", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Rooms_HostId", - table: "Rooms", - column: "HostId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Hands"); - - migrationBuilder.DropTable( - name: "RoomPlayers"); - - migrationBuilder.DropTable( - name: "Rooms"); - - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/Project.App/Project.Api/Migrations/AppDbContextModelSnapshot.cs b/Project.App/Project.Api/Migrations/AppDbContextModelSnapshot.cs deleted file mode 100644 index 28a7d4a..0000000 --- a/Project.App/Project.Api/Migrations/AppDbContextModelSnapshot.cs +++ /dev/null @@ -1,236 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Project.Api.Data; - -#nullable disable - -namespace Project.Api.Migrations -{ - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Project.Api.Models.Hand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Bet") - .HasColumnType("int"); - - b.Property("Order") - .HasColumnType("int"); - - b.Property("RoomPlayerId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("RoomPlayerId"); - - b.ToTable("Hands"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("DeckId") - .HasColumnType("int"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("EndedAt") - .HasColumnType("datetime2"); - - b.Property("GameConfig") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("GameMode") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("GameState") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("HostId") - .HasColumnType("uniqueidentifier"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("IsPublic") - .HasColumnType("bit"); - - b.Property("MaxPlayers") - .HasColumnType("int"); - - b.Property("MinPlayers") - .HasColumnType("int"); - - b.Property("Round") - .HasColumnType("int"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("rowversion"); - - b.Property("StartedAt") - .HasColumnType("datetime2"); - - b.Property("State") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("HostId"); - - b.ToTable("Rooms"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Balance") - .HasColumnType("bigint"); - - b.Property("BalanceDelta") - .HasColumnType("bigint"); - - b.Property("Role") - .HasColumnType("int"); - - b.Property("RoomId") - .HasColumnType("uniqueidentifier"); - - b.Property("Status") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("RoomId"); - - b.HasIndex("UserId"); - - b.ToTable("RoomPlayers"); - }); - - modelBuilder.Entity("Project.Api.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("AvatarUrl") - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("Balance") - .HasColumnType("float"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Project.Api.Models.Hand", b => - { - b.HasOne("Project.Api.Models.RoomPlayer", "RoomPlayer") - .WithMany("Hands") - .HasForeignKey("RoomPlayerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("RoomPlayer"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.HasOne("Project.Api.Models.User", "Host") - .WithMany() - .HasForeignKey("HostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Host"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.HasOne("Project.Api.Models.Room", "Room") - .WithMany("RoomPlayers") - .HasForeignKey("RoomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Project.Api.Models.User", "User") - .WithMany("RoomPlayers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Room"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Project.Api.Models.Room", b => - { - b.Navigation("RoomPlayers"); - }); - - modelBuilder.Entity("Project.Api.Models.RoomPlayer", b => - { - b.Navigation("Hands"); - }); - - modelBuilder.Entity("Project.Api.Models.User", b => - { - b.Navigation("RoomPlayers"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index 112705f..a96939e 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -1,9 +1,5 @@ -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; -using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.EntityFrameworkCore; using Project.Api.Data; using Project.Api.Repositories; @@ -21,17 +17,27 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - // use adminsetting.json as configuration + // Load configuration from multiple sources (in order of precedence): + // 1. appsettings.json (base configuration) + // 2. appsettings.{Environment}.json (environment-specific) + // 3. adminsetting.json (optional admin overrides) + // 4. Environment variables (highest priority, can override anything) + // 5. User secrets (for local development only) builder.Configuration.AddJsonFile( "adminsetting.json", optional: true, reloadOnChange: true ); + builder.Configuration.AddEnvironmentVariables(prefix: "DAUNTING_"); - // configure Serilog + // Configure Serilog with environment-aware settings Log.Logger = new LoggerConfiguration() - .MinimumLevel.Debug() - .WriteTo.Console() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + ) .CreateLogger(); builder.Host.UseSerilog(); @@ -39,47 +45,23 @@ public static void Main(string[] args) // use extension methods to configure services builder.Services.AddDatabase(builder.Configuration); builder.Services.AddApplicationServices(); - builder.Services.AddCorsPolicy(); + builder.Services.AddCorsPolicy(builder.Configuration); builder.Services.AddAuth(builder.Configuration, builder.Environment); builder.Services.AddControllers(); builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); + builder.Services.AddHealthChecks(); var app = builder.Build(); - // optional/safe auto-migrate in other words our startup wont crash if the our migration model doenst match the DB - using (var scope = app.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - try - { - db.Database.Migrate(); - Log.Information("[Startup] Database migration check complete."); - } - catch (Exception ex) - { - Log.Warning( - ex, - "[Startup] Database migration skipped or failed. Continuing startup." - ); - } - } - app.UseMiddleware(); - // connection string print - app.MapGet( - "/string", - () => - { - var CS = builder.Configuration.GetConnectionString("DefaultConnection"); - return Results.Ok(CS); - } - ); + // Health check endpoint (useful for monitoring and load balancers) + app.MapHealthChecks("/health"); - // is development envrionment + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -104,7 +86,10 @@ public static class ProgramExtensions /// /// Applies the configuration for the CORS policy. /// - public static IServiceCollection AddCorsPolicy(this IServiceCollection services) + public static IServiceCollection AddCorsPolicy( + this IServiceCollection services, + IConfiguration configuration + ) { services.AddCors(options => { @@ -112,8 +97,12 @@ public static IServiceCollection AddCorsPolicy(this IServiceCollection services) CorsPolicy, policy => { + var allowedOrigins = + configuration.GetSection("CorsSettings:AllowedOrigins").Get() + ?? new[] { "http://localhost:3000" }; + policy - .WithOrigins("http://localhost:3000", "https://localhost:3000") // Next.js frontend + .WithOrigins(allowedOrigins) // Read from configuration .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); // Required for cookies @@ -151,6 +140,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // singleton services services.AddHttpClient(); services.AddSingleton(); + services.AddHostedService(); // scoped repositories services.AddScoped(); @@ -173,19 +163,23 @@ public static IServiceCollection AddAuth( IWebHostEnvironment environment ) { - // sanity check for auth errors logging + // Validate required OAuth configuration if (!environment.IsEnvironment("Testing")) { var gid = configuration["Google:ClientId"]; var gsec = configuration["Google:ClientSecret"]; + + // Allow environment variables to override config file + // e.g., DAUNTING_Google__ClientId, DAUNTING_Google__ClientSecret if (string.IsNullOrWhiteSpace(gid) || string.IsNullOrWhiteSpace(gsec)) { throw new InvalidOperationException( - "Google OAuth config missing. Set Google:ClientId and Google:ClientSecret." + "Google OAuth config missing. Set Google:ClientId and Google:ClientSecret in " + + "appsettings.json, user secrets, or environment variables (DAUNTING_Google__ClientId, DAUNTING_Google__ClientSecret)." ); } Log.Information( - "Google ClientId (first 8): {ClientId}", + "Google OAuth configured. ClientId (first 8): {ClientId}", gid?.Length >= 8 ? gid[..8] : gid ); } @@ -197,14 +191,13 @@ IWebHostEnvironment environment // where the app reads identity from on each request options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; // how the app prompts an unauthenticated user to log in - // you can set DefaultChallengeScheme to Cookies instead but it didnt work before options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; }) .AddCookie(cookie => { // cross-site cookie SPA on :3000 to API on :7069 cookie.Cookie.SameSite = SameSiteMode.None; - // browsers require Secure when SameSite=None + // browsers require Secure when SameSite=None this is why we need https instead of http cookie.Cookie.SecurePolicy = CookieSecurePolicy.Always; // for APIs return status codes instead of 302 redirects @@ -224,93 +217,71 @@ IWebHostEnvironment environment }) .AddGoogle(options => { - options.ClientId = configuration["Google:ClientId"]!; - options.ClientSecret = configuration["Google:ClientSecret"]!; - options.CallbackPath = "/auth/google/callback"; - options.Scope.Add("email"); - options.Scope.Add("profile"); - - // ensure we actually fetch profile claims from Google UserInfo endpoint - options.SaveTokens = true; - options.UserInformationEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo"; - options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); - options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name"); - options.ClaimActions.MapJsonKey("picture", "picture"); - options.ClaimActions.MapJsonKey("email_verified", "email_verified"); - - // Upsert user into DB when Google signs in successfully - options.Events = new OAuthEvents + options.ClientId = configuration["Google:ClientId"]!; // from secrets / config + options.ClientSecret = configuration["Google:ClientSecret"]!; // from secrets / config + options.CallbackPath = "/auth/google/callback"; // google redirects here after login if we change this we need to change it on google cloud as well! + options.Events = new Microsoft.AspNetCore.Authentication.OAuth.OAuthEvents { OnCreatingTicket = async ctx => { - var log = ctx.HttpContext.RequestServices.GetRequiredService< + var logger = ctx.HttpContext.RequestServices.GetRequiredService< ILogger >(); - - // fetch the full user JSON from Google's userinfo endpoint - var request = new System.Net.Http.HttpRequestMessage( - System.Net.Http.HttpMethod.Get, - ctx.Options.UserInformationEndpoint - ); - request.Headers.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue( - "Bearer", - ctx.AccessToken - ); - - var response = await ctx.Backchannel.SendAsync(request); - response.EnsureSuccessStatusCode(); - - using var userJson = JsonDocument.Parse( - await response.Content.ReadAsStringAsync() - ); - var j = userJson.RootElement; - - var email = j.TryGetProperty("email", out var e) ? e.GetString() : null; - var verified = - j.TryGetProperty("email_verified", out var v) && v.GetBoolean(); - var name = j.TryGetProperty("name", out var n) ? n.GetString() : null; - var picture = j.TryGetProperty("picture", out var p) ? p.GetString() : null; - - log.LogInformation( - "[OAuth] CreatingTicket: email={Email}, verified={Verified}, name={Name}", - email, - verified, - name + logger.LogInformation( + "[OAuth] OnCreatingTicket fired - processing Google login" ); - if (string.IsNullOrWhiteSpace(email) || !verified) - { - log.LogWarning("[OAuth] Missing or unverified email; failing ticket."); - ctx.Fail("Google email must be present and verified."); - return; - } - try { - using var scope = ctx.HttpContext.RequestServices.CreateScope(); - var users = scope.ServiceProvider.GetRequiredService(); - - var user = await users.UpsertGoogleUserByEmailAsync( - email!, - name, - picture + var email = ctx.User.GetProperty("email").GetString(); + var verified = + ctx.User.TryGetProperty("email_verified", out var ev) + && ev.GetBoolean(); + var name = ctx.User.TryGetProperty("name", out var n) + ? n.GetString() + : null; + var picture = ctx.User.TryGetProperty("picture", out var p) + ? p.GetString() + : null; + + logger.LogInformation( + "[OAuth] Email: {Email}, Verified: {Verified}, Name: {Name}", + email, + verified, + name ); - log.LogInformation( - "[OAuth] Upsert complete: UserId={UserId}, Email={UserEmail}", - user.Id, - user.Email + if (string.IsNullOrWhiteSpace(email) || !verified) + { + logger.LogWarning( + "[OAuth] Email validation failed - Email: {Email}, Verified: {Verified}", + email, + verified + ); + ctx.Fail("Google email must be present and verified."); + return; + } + + var svc = + ctx.HttpContext.RequestServices.GetRequiredService(); + logger.LogInformation( + "[OAuth] Calling UpsertGoogleUserByEmailAsync for {Email}", + email + ); + await svc.UpsertGoogleUserByEmailAsync(email, name, picture); + logger.LogInformation( + "[OAuth] Successfully upserted user for {Email}", + email ); } catch (Exception ex) { - log.LogError( + logger.LogError( ex, - "[OAuth] Error during UpsertGoogleUserByEmailAsync for {Email}", - email + "[OAuth] Error in OnCreatingTicket: {Message}", + ex.Message ); - ctx.Fail("Internal error during user creation."); + throw; } }, }; diff --git a/Project.App/Project.Api/Repositories/Interface/IUserRepository.cs b/Project.App/Project.Api/Repositories/Interface/IUserRepository.cs index 9e96e18..2d57fcd 100644 --- a/Project.App/Project.Api/Repositories/Interface/IUserRepository.cs +++ b/Project.App/Project.Api/Repositories/Interface/IUserRepository.cs @@ -1,4 +1,3 @@ -using System.Reflection.Metadata; using Project.Api.Models; namespace Project.Api.Repositories.Interface @@ -10,7 +9,6 @@ public interface IUserRepository Task GetByEmailAsync(string email); Task AddAsync(User user); Task UpdateAsync(User user); - Task UpdateBalanceAsync(User user); Task DeleteAsync(Guid id); Task SaveChangesAsync(); } diff --git a/Project.App/Project.Api/Repositories/UserRepository.cs b/Project.App/Project.Api/Repositories/UserRepository.cs index 82d2389..f9541ba 100644 --- a/Project.App/Project.Api/Repositories/UserRepository.cs +++ b/Project.App/Project.Api/Repositories/UserRepository.cs @@ -49,13 +49,6 @@ public async Task UpdateAsync(User user) await _context.SaveChangesAsync(); } - // Update user balance - public async Task UpdateBalanceAsync(User user) - { - _context.Users.Update(user); - await _context.SaveChangesAsync(); - } - // Delete user by ID public async Task DeleteAsync(Guid id) { diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 33c9651..62a466f 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -89,6 +89,22 @@ public BlackjackConfig Config set => _config = value; } + // JSON serialization options with type discriminators for polymorphic types + private static readonly JsonSerializerOptions _gameStateJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() + }; + + /// + /// Serialize game state with proper options to preserve type discriminators. + /// + private static string SerializeGameState(BlackjackState state) + { + return JsonSerializer.Serialize(state, _gameStateJsonOptions); + } + public async Task GetGameStateAsync(Guid roomId) { string stateString = await _roomRepository.GetGameStateAsync(roomId); @@ -148,7 +164,7 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) // set bet in gamestate stage.Bets[player.Id] = betAction.Amount; - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); // update player status player.Status = Status.Active; @@ -176,17 +192,15 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) } } - // move to next stage + // move to dealing stage + await DealCardsAsync(state, roomId); + // move to player action stage state.CurrentStage = new BlackjackPlayerActionStage( DateTimeOffset.UtcNow + _config.TurnTimeLimit, 0 ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); - - // TODO: dealing stage - - // TODO: move to player action stage + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); break; case HitAction hitAction: @@ -256,49 +270,174 @@ await _roomRepository.GetByIdAsync(roomId) await NextHandOrFinishRoundAsync(state, roomId); break; case DoubleAction doubleAction: - // can only be done on the player's first turn! + // Get player's hand + var doubleHands = + await _handRepository.GetHandsByRoomIdAsync(player.Id) + ?? throw new BadRequestException("No hand found for this player."); + var doubleHand = + doubleHands.FirstOrDefault() + ?? throw new BadRequestException("No hand found for this player."); - // check if player has enough chips to double their bet + // Get room to access deck + var doubleRoom = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new BadRequestException("Room not found."); - // double player's bet (deduct from balance and update gamestate) + // Verify this is the first turn (hand has exactly 2 cards) + var doubleCards = await _deckApiService.GetHandCards( + doubleRoom.DeckId!, + doubleHand.Id.ToString() + ); + if (doubleCards.Count != 2) + { + throw new BadRequestException( + "Double down can only be done on the first turn with exactly 2 cards." + ); + } - // draw one card + // Check if player has enough chips to double their bet + if (player.Balance < doubleHand.Bet) + { + throw new BadRequestException( + $"Player {playerId} does not have enough chips to double down." + ); + } - // next player or next stage + // Double the bet (deduct from balance and update hand) + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -doubleHand.Bet); + doubleHand.Bet *= 2; + await _handRepository.UpdateHandAsync(doubleHand.Id, doubleHand); + + // Draw exactly one card + await _deckApiService.DrawCards(doubleRoom.DeckId!, doubleHand.Id.ToString(), 1); + + // Automatically stand after drawing (double down rule) await NextHandOrFinishRoundAsync(state, roomId); - throw new NotImplementedException(); + break; case SplitAction splitAction: - // can only be done on the player's first turn! + // Get player's hand + var splitHands = + await _handRepository.GetHandsByRoomIdAsync(player.Id) + ?? throw new BadRequestException("No hand found for this player."); + var splitHand = + splitHands.FirstOrDefault() + ?? throw new BadRequestException("No hand found for this player."); + + // Get room to access deck + var splitRoom = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new BadRequestException("Room not found."); + + // Verify this is the first turn (hand has exactly 2 cards with same value) + var splitCards = await _deckApiService.GetHandCards( + splitRoom.DeckId!, + splitHand.Id.ToString() + ); + if (splitCards.Count != 2) + { + throw new BadRequestException( + "Split can only be done on the first turn with exactly 2 cards." + ); + } - // check if player has enough chips to do the new bet + // Check if both cards have the same value + string value1 = splitCards[0].Value.ToUpper(); + string value2 = splitCards[1].Value.ToUpper(); - // deal new hand (2 cards) + // Normalize face cards to "10" + int GetCardNumericValue(string val) => + val switch + { + "JACK" or "QUEEN" or "KING" => 10, + _ => int.TryParse(val, out int v) ? v : (val == "ACE" ? 1 : 0), + }; - // next turn should be player's first hand - // can only be done on the player's first turn! + if (GetCardNumericValue(value1) != GetCardNumericValue(value2)) + { + throw new BadRequestException( + "Split can only be done when both cards have the same value." + ); + } - // check if player has enough chips to do the new bet + // Check if player has enough chips for the new bet + if (player.Balance < splitHand.Bet) + { + throw new BadRequestException( + $"Player {playerId} does not have enough chips to split." + ); + } - // deal new hand (2 cards) + // Deduct bet for second hand + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -splitHand.Bet); - // next turn should be player's first hand - throw new NotImplementedException(); + // Create second hand with same bet (empty, we'll add a card from deck API) + var secondHand = new Hand + { + Id = Guid.NewGuid(), + RoomPlayerId = player.Id, + Order = 1, + Bet = splitHand.Bet, + }; + await _handRepository.CreateHandAsync(secondHand); + + // Note: In the Deck API, we can't move cards between piles, so we keep both cards + // in the first hand and just create a new empty hand for the second + // The split is logical - each hand will get additional cards drawn + + // Create empty pile for second hand and draw one card for each + await _deckApiService.CreateEmptyHand(splitRoom.DeckId!, secondHand.Id.ToString()); + await _deckApiService.DrawCards(splitRoom.DeckId!, splitHand.Id.ToString(), 1); + await _deckApiService.DrawCards(splitRoom.DeckId!, secondHand.Id.ToString(), 1); + + // Continue with player's turn (they can hit/stand on each hand) + break; case SurrenderAction surrenderAction: - // not allowed after splitting! - // maybe check if player only has one hand? + // Get player's hands + var surrenderHands = + await _handRepository.GetHandsByRoomIdAsync(player.Id) + ?? throw new BadRequestException("No hand found for this player."); - // refund half of player's bet (deduct from balance and update gamestate) + // Not allowed after splitting (player should only have one hand) + if (surrenderHands.Count() > 1) + { + throw new BadRequestException("Surrender is not allowed after splitting."); + } - // next player or next stage - await NextHandOrFinishRoundAsync(state, roomId); - // not allowed after splitting! - // maybe check if player only has one hand? + var surrenderHand = + surrenderHands.FirstOrDefault() + ?? throw new BadRequestException("No hand found for this player."); - // refund half of player's bet (deduct from balance and update gamestate) + // Get room to access deck + var surrenderRoom = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new BadRequestException("Room not found."); - // next player or next stage + // Verify this is the first turn (hand has exactly 2 cards) + var surrenderCards = await _deckApiService.GetHandCards( + surrenderRoom.DeckId!, + surrenderHand.Id.ToString() + ); + if (surrenderCards.Count != 2) + { + throw new BadRequestException( + "Surrender can only be done on the first turn with exactly 2 cards." + ); + } + + // Refund half of player's bet + long refund = surrenderHand.Bet / 2; + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, refund); + + // Mark hand as inactive or delete it + await _handRepository.DeleteHandAsync(surrenderHand.Id); + + // Mark player as inactive for this round + player.Status = Status.Inactive; + await _roomPlayerRepository.UpdateAsync(player); + + // Move to next player or finish round await NextHandOrFinishRoundAsync(state, roomId); - throw new NotImplementedException(); + break; case HurryUpAction hurryUpAction: if (state.CurrentStage is BlackjackBettingStage bettingStage) { @@ -326,19 +465,18 @@ await _roomRepository.GetByIdAsync(roomId) } } - // Move to next stage + // Deal cards to all players and dealer + await DealCardsAsync(state, roomId); + + // Move to player action stage state.CurrentStage = new BlackjackPlayerActionStage( DateTimeOffset.UtcNow + _config.TurnTimeLimit, 0 ); await _roomRepository.UpdateGameStateAsync( roomId, - JsonSerializer.Serialize(state) + SerializeGameState(state) ); - - // TODO: dealing stage - - // TODO: move to player action stage } else if (state.CurrentStage is BlackjackPlayerActionStage playerActionStage) { @@ -386,7 +524,7 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) DateTimeOffset.UtcNow + _config.TurnTimeLimit, nextIndex ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); } else { @@ -406,39 +544,132 @@ private async Task FinishRoundAsync(BlackjackState state, Guid roomId) { // Transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); + + // Get room to access deck ID + var room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new BadRequestException("Room not found."); - // TODO: Reveal dealer's hidden card (requires deck API integration) + if (string.IsNullOrEmpty(room.DeckId)) + { + throw new InternalServerException($"Deck for room {roomId} not found."); + } + + // Dealer's hidden card is already revealed (it's in state.DealerHand) + // Now dealer plays - hit until 17 or higher + var dealerCards = state + .DealerHand.Cast() + .Select(je => JsonSerializer.Deserialize(je.GetRawText())!) + .ToList(); + + // Create dealer hand in deck API to draw more cards if needed + var dealerHandId = Guid.NewGuid(); + await _deckApiService.CreateEmptyHand(room.DeckId, dealerHandId.ToString()); + + // Dealer hits until 17 or higher + while (CalculateHandValue(dealerCards) < 17) + { + var newCards = await _deckApiService.DrawCards(room.DeckId, dealerHandId.ToString(), 1); + dealerCards.AddRange(newCards); + } + + // Update dealer hand in state + state.DealerHand = dealerCards.Cast().ToList(); + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); - // TODO: Dealer plays - hit until 17 or higher (requires deck API integration) - // For now, calculate dealer value from existing hand - // In a complete implementation: - // while (CalculateHandValue(state.DealerHand) < 17) - // { - // var card = await DrawCardFromDeck(roomId); - // state.DealerHand.Add(card); - // } + int dealerValue = CalculateHandValue(dealerCards); + bool dealerBusted = dealerValue > 21; + bool dealerHasBlackjack = IsBlackjack(dealerCards); - // TODO: Calculate winnings for each player hand // Get all active players IEnumerable activePlayers = await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId); - // For each player: - // 1. Get their hands from the database - // 2. Calculate hand value - // 3. Compare with dealer hand - // 4. Determine winnings (blackjack pays 3:2, regular win pays 1:1, push returns bet) - // 5. Update player balance - + // Calculate winnings for each player foreach (RoomPlayer player in activePlayers) { - // TODO: Implement hand evaluation and payout logic - // This requires: - // - Getting player hands from database - // - Parsing card data from JSON - // - Comparing with dealer hand - // - Updating balances via repository + // Get player hands from database + var hands = await _handRepository.GetHandsByRoomIdAsync(player.Id); + if (hands == null || !hands.Any()) + continue; + + foreach (var hand in hands) + { + // Get cards from Deck API + List playerCards; + try + { + playerCards = await _deckApiService.GetHandCards( + room.DeckId, + hand.Id.ToString() + ); + if (!playerCards.Any()) + continue; + } + catch + { + continue; // Skip if can't get cards + } + + int playerValue = CalculateHandValue(playerCards); + bool playerBusted = playerValue > 21; + bool playerHasBlackjack = IsBlackjack(playerCards); + + long winnings = 0; + + // Determine winnings + if (playerBusted) + { + // Player busted - loses bet (already deducted) + winnings = 0; + } + else if (dealerBusted) + { + // Dealer busted - player wins + if (playerHasBlackjack) + { + // Blackjack pays 3:2 + winnings = hand.Bet + (hand.Bet * 3 / 2); + } + else + { + // Regular win pays 1:1 + winnings = hand.Bet * 2; + } + } + else if (playerHasBlackjack && !dealerHasBlackjack) + { + // Player blackjack beats dealer non-blackjack - pays 3:2 + winnings = hand.Bet + (hand.Bet * 3 / 2); + } + else if (dealerHasBlackjack && !playerHasBlackjack) + { + // Dealer blackjack beats player non-blackjack - player loses + winnings = 0; + } + else if (playerValue > dealerValue) + { + // Player wins - pays 1:1 + winnings = hand.Bet * 2; + } + else if (playerValue < dealerValue) + { + // Dealer wins - player loses + winnings = 0; + } + else + { + // Push - return bet + winnings = hand.Bet; + } + + // Update player balance + if (winnings > 0) + { + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, winnings); + } + } } // Initialize next betting stage @@ -450,6 +681,116 @@ private async Task FinishRoundAsync(BlackjackState state, Guid roomId) // Reset dealer hand for next round state.DealerHand = []; - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); + } + + /// + /// Deal initial cards to all active players (2 cards each) and dealer (2 cards, one hidden). + /// + private async Task DealCardsAsync(BlackjackState state, Guid roomId) + { + // Get room to access deck ID + var room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new BadRequestException("Room not found."); + + if (string.IsNullOrEmpty(room.DeckId)) + { + throw new InternalServerException($"Deck for room {roomId} not found."); + } + + // Get all active players who placed bets + IEnumerable activePlayers = + await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId); + + // Deal 2 cards to each player + foreach (var player in activePlayers) + { + // Get or create player's hand + var hands = await _handRepository.GetHandsByRoomIdAsync(player.Id); + var hand = hands?.FirstOrDefault(); + + if (hand == null) + { + // Create new hand if it doesn't exist + hand = new Hand + { + Id = Guid.NewGuid(), + RoomPlayerId = player.Id, + Order = 0, + Bet = 0, + }; + await _handRepository.CreateHandAsync(hand); + + // Create empty pile in Deck API + await _deckApiService.CreateEmptyHand(room.DeckId, hand.Id.ToString()); + } + + // Draw 2 cards for the player + await _deckApiService.DrawCards(room.DeckId, hand.Id.ToString(), 2); + } + + // Deal 2 cards to dealer and store in game state + // Create a temporary hand for the dealer + var dealerHandId = Guid.NewGuid(); + await _deckApiService.CreateEmptyHand(room.DeckId, dealerHandId.ToString()); + var dealerCards = await _deckApiService.DrawCards(room.DeckId, dealerHandId.ToString(), 2); + + // Store dealer cards in state (first card is visible, second is hidden) + state.DealerHand = dealerCards.Cast().ToList(); + + await _roomRepository.UpdateGameStateAsync(roomId, SerializeGameState(state)); + } + + /// + /// Calculate the value of a hand, accounting for Aces (1 or 11). + /// + private int CalculateHandValue(List cards) + { + int totalValue = 0; + int aceCount = 0; + + foreach (var card in cards) + { + switch (card.Value.ToUpper()) + { + case "ACE": + aceCount++; + totalValue += 11; + break; + case "KING": + case "QUEEN": + case "JACK": + totalValue += 10; + break; + default: + if (int.TryParse(card.Value, out int val)) + totalValue += val; + break; + } + } + + // Adjust for Aces if busted + while (totalValue > 21 && aceCount > 0) + { + totalValue -= 10; + aceCount--; + } + + return totalValue; + } + + /// + /// Check if a hand is a blackjack (Ace + 10-value card). + /// + private bool IsBlackjack(List cards) + { + if (cards.Count != 2) + return false; + + bool hasAce = cards.Any(c => c.Value.ToUpper() == "ACE"); + bool hasTen = cards.Any(c => c.Value.ToUpper() is "10" or "JACK" or "QUEEN" or "KING"); + + return hasAce && hasTen; } } diff --git a/Project.App/Project.Api/Services/DeckApiService.cs b/Project.App/Project.Api/Services/DeckApiService.cs index ef753dd..3c244ce 100644 --- a/Project.App/Project.Api/Services/DeckApiService.cs +++ b/Project.App/Project.Api/Services/DeckApiService.cs @@ -9,6 +9,7 @@ namespace Project.Api.Services; public class DeckApiService : IDeckApiService { private readonly HttpClient _httpClient; + private readonly string _baseApiUrl; private readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -16,11 +17,10 @@ public class DeckApiService : IDeckApiService NumberHandling = JsonNumberHandling.AllowReadingFromString, }; - private const string BASE_API_URL = "https://deckofcardsapi.com/api"; - - public DeckApiService(HttpClient client) + public DeckApiService(HttpClient client, IConfiguration configuration) { _httpClient = client; + _baseApiUrl = configuration["DeckApiSettings:BaseUrl"] ?? "https://deckofcardsapi.com/api"; } /// @@ -34,7 +34,7 @@ public DeckApiService(HttpClient client) public async Task CreateDeck(int numOfDecks = 6, bool enableJokers = false) { string url = - $"{BASE_API_URL}/deck/new/shuffle/?deck_count={numOfDecks}&enable_Jokers={enableJokers}"; + $"{_baseApiUrl}/deck/new/shuffle/?deck_count={numOfDecks}&enable_Jokers={enableJokers}"; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) { @@ -54,7 +54,7 @@ public async Task CreateDeck(int numOfDecks = 6, bool enableJokers = fal /// true if successful public async Task CreateEmptyHand(string deckId, string handName) { - string url = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/add/?cards="; + string url = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/add/?cards="; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) { @@ -97,7 +97,7 @@ public async Task> DrawCards(string deckId, string handName, int c } // draw cards from deck - string drawUrl = $"{BASE_API_URL}/deck/{deckId}/draw/?count={count}"; + string drawUrl = $"{_baseApiUrl}/deck/{deckId}/draw/?count={count}"; var drawResponse = await _httpClient.GetAsync(drawUrl); drawResponse.EnsureSuccessStatusCode(); @@ -128,7 +128,7 @@ public async Task> DrawCards(string deckId, string handName, int c /// true if successful public async Task ReturnAllCardsToDeck(string deckId) { - string url = $"{BASE_API_URL}/deck/{deckId}/return/"; + string url = $"{_baseApiUrl}/deck/{deckId}/return/"; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) @@ -144,8 +144,7 @@ public async Task ReturnAllCardsToDeck(string deckId) /// true if successful private async Task AddToHand(string deckId, string handName, string cardCodes) { - string addToPileUrl = - $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/add/?cards={cardCodes}"; + string addToPileUrl = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/add/?cards={cardCodes}"; var addResponse = await _httpClient.GetAsync(addToPileUrl); if (!addResponse.IsSuccessStatusCode) { @@ -154,13 +153,29 @@ private async Task AddToHand(string deckId, string handName, string cardCo return true; } + /// + /// Get all cards currently in a hand (pile) without drawing new ones. + /// + public async Task> GetHandCards(string deckId, string handName) + { + return await ListHand(deckId, handName); + } + + /// + /// Get all cards currently in a hand (pile) without drawing new ones. + /// + public async Task> GetHandCards(string deckId, long handId) + { + return await ListHand(deckId, handId.ToString()); + } + /// /// Calls Api to list cards in specified pile from specified deck. /// /// A list of card DTOs private async Task> ListHand(string deckId, string handName) { - string listPileUrl = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/list/"; + string listPileUrl = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/list/"; var listResponse = await _httpClient.GetAsync(listPileUrl); listResponse.EnsureSuccessStatusCode(); diff --git a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs index ed9d8b3..82fdc9b 100644 --- a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs +++ b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs @@ -45,4 +45,16 @@ Return all cards from all piles back to the main deck and shuffles deck. Returns true if successful. */ Task ReturnAllCardsToDeck(string deckId); + + /* + Get all cards currently in a hand (pile) without drawing new ones. + Returns list of cards in the hand. + */ + Task> GetHandCards(string deckId, string handName); + + /* + Get all cards currently in a hand (pile) without drawing new ones. + Returns list of cards in the hand. + */ + Task> GetHandCards(string deckId, long handId); } diff --git a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs index a9796bf..8b6b808 100644 --- a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs +++ b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs @@ -1,6 +1,6 @@ namespace Project.Api.Services.Interface; -public interface IRoomSSEService +public interface IRoomSSEService : IDisposable { /// /// Adds a new client connection for a specific room and keeps it open. @@ -11,4 +11,9 @@ public interface IRoomSSEService /// Broadcasts an event to all clients connected to a specific room. /// Task BroadcastEventAsync(Guid roomId, string eventName, object data); + + /// + /// Closes all SSE connections for graceful shutdown. + /// + Task CloseAllConnectionsAsync(); } diff --git a/Project.App/Project.Api/Services/RoomSSEService.cs b/Project.App/Project.Api/Services/RoomSSEService.cs index dffae98..b94a381 100644 --- a/Project.App/Project.Api/Services/RoomSSEService.cs +++ b/Project.App/Project.Api/Services/RoomSSEService.cs @@ -64,7 +64,20 @@ out ConcurrentDictionary? connections return; } - string eventPayload = $"event: {eventName}\ndata: {JsonSerializer.Serialize(data)}\n\n"; + // Use camelCase naming to match ASP.NET Core controller responses + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + string serializedData = JsonSerializer.Serialize(data, options); + Console.WriteLine( + $"[SSE] Broadcasting to room {roomId}: event={eventName}, data length={serializedData.Length}" + ); + Console.WriteLine( + $"[SSE] Data preview: {serializedData.Substring(0, Math.Min(200, serializedData.Length))}..." + ); + + string eventPayload = $"event: {eventName}\ndata: {serializedData}\n\n"; List closedConnections = []; foreach ((string connectionId, StreamWriter writer) in connections) @@ -100,4 +113,33 @@ out ConcurrentDictionary? connections } } } + + public async Task CloseAllConnectionsAsync() + { + Console.WriteLine("[SSE] Closing all SSE connections for graceful shutdown..."); + + foreach (var roomConnections in _connections.Values) + { + foreach (var writer in roomConnections.Values) + { + try + { + await writer.DisposeAsync(); + } + catch + { + // Ignore errors during shutdown + } + } + roomConnections.Clear(); + } + _connections.Clear(); + + Console.WriteLine("[SSE] All SSE connections closed."); + } + + public void Dispose() + { + CloseAllConnectionsAsync().GetAwaiter().GetResult(); + } } diff --git a/Project.App/Project.Api/Services/RoomService.cs b/Project.App/Project.Api/Services/RoomService.cs index 64e5ce7..7ab6dca 100644 --- a/Project.App/Project.Api/Services/RoomService.cs +++ b/Project.App/Project.Api/Services/RoomService.cs @@ -17,6 +17,7 @@ public class RoomService( IRoomPlayerRepository roomPlayerRepository, IBlackjackService blackjackService, IDeckApiService deckApiService, + IRoomSSEService roomSSEService, AppDbContext dbContext, ILogger logger ) : IRoomService @@ -25,6 +26,7 @@ ILogger logger private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; private readonly IBlackjackService _blackjackService = blackjackService; private readonly IDeckApiService _deckApiService = deckApiService; + private readonly IRoomSSEService _roomSSEService = roomSSEService; private readonly AppDbContext _dbContext = dbContext; private readonly ILogger _logger = logger; @@ -88,6 +90,25 @@ public async Task CreateRoomAsync(CreateRoomDTO dto) }; var createdRoom = await _roomRepository.CreateAsync(room); + + // Automatically add the host as a player with Admin role + var hostPlayer = new RoomPlayer + { + Id = Guid.NewGuid(), + RoomId = createdRoom.Id, + UserId = dto.HostId, + Role = Role.Admin, + Status = Status.Active, + Balance = 0, // Will be set when game starts + }; + + await _roomPlayerRepository.CreateAsync(hostPlayer); + _logger.LogInformation( + "Host {UserId} automatically added to room {RoomId} as Admin", + dto.HostId, + createdRoom.Id + ); + return MapToResponseDto(createdRoom); } @@ -253,7 +274,16 @@ await _roomPlayerRepository.UpdatePlayersInRoomAsync( ), DealerHand = [], }; - initialGameState = JsonSerializer.Serialize(blackjackState); + + // Serialize with options to include type discriminators for polymorphism + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() + }; + initialGameState = JsonSerializer.Serialize(blackjackState, jsonOptions); + _logger.LogInformation("Serialized game state: {GameState}", initialGameState); // Create empty hands in the deck for each player and the dealer if (string.IsNullOrWhiteSpace(room.DeckId)) @@ -312,6 +342,15 @@ await _roomRepository.UpdateAsync(room) ?? throw new InternalServerException("Failed to update room."); _logger.LogInformation("Successfully started game for room {RoomId}", roomId); + + // Broadcast game started event to all players via SSE + await _roomSSEService.BroadcastEventAsync( + roomId, + "room_updated", + MapToResponseDto(updatedRoom) + ); + _logger.LogInformation("Broadcasted game start for room {RoomId}", roomId); + return MapToResponseDto(updatedRoom); } @@ -368,6 +407,17 @@ await _roomRepository.GetByIdAsync(roomId) playerId, roomId ); + + // Broadcast game state update via SSE + var updatedRoom = await _roomRepository.GetByIdAsync(roomId); + if (updatedRoom != null) + { + await _roomSSEService.BroadcastEventAsync( + roomId, + "room_updated", + MapToResponseDto(updatedRoom) + ); + } break; default: @@ -456,8 +506,29 @@ await _roomRepository.GetByIdAsync(roomId) throw new ConflictException($"Player {userId} is already in room {roomId}."); } - // Return the room (no need to fetch again, we have it) - return MapToResponseDto(room); + // Refetch the room to get updated RoomPlayers collection + var updatedRoom = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room with ID {roomId} not found."); + + _logger.LogInformation( + "Broadcasting join event for room {RoomId}. MaxPlayers: {MaxPlayers}, PlayerCount: {PlayerCount}", + roomId, + updatedRoom.MaxPlayers, + updatedRoom.RoomPlayers.Count + ); + + // Broadcast player joined event via SSE + var roomDto = MapToResponseDto(updatedRoom); + _logger.LogInformation( + "RoomDTO to broadcast - MaxPlayers: {MaxPlayers}, MinPlayers: {MinPlayers}", + roomDto.MaxPlayers, + roomDto.MinPlayers + ); + await _roomSSEService.BroadcastEventAsync(roomId, "room_updated", roomDto); + + // Return the updated room + return roomDto; } public async Task LeaveRoomAsync(Guid roomId, Guid userId) @@ -478,40 +549,65 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, userId) if (room.HostId == userId) { _logger.LogInformation( - "Host {UserId} leaving room {RoomId}. Closing room and removing all players", + "Host {UserId} leaving room {RoomId}. Checking for other players...", userId, roomId ); - // Host is leaving - close the room within a transaction + // Get all players in the room + var allPlayers = await _roomPlayerRepository.GetByRoomIdAsync(roomId); + var otherPlayers = allPlayers.Where(p => p.UserId != userId).ToList(); + + // Host is leaving - check if there are other players await using var transaction = await _dbContext.Database.BeginTransactionAsync(); try { - // Mark room as inactive - room.IsActive = false; - room.EndedAt = DateTime.UtcNow; - await _roomRepository.UpdateAsync(room); + if (otherPlayers.Any()) + { + // Transfer host to the next player (first one we find) + var newHost = otherPlayers.First(); + room.HostId = newHost.UserId; + await _roomRepository.UpdateAsync(room); + + // Update the new host's role to Admin + newHost.Role = Role.Admin; + await _roomPlayerRepository.UpdateAsync(newHost); - // Remove all players - var allPlayers = await _roomPlayerRepository.GetByRoomIdAsync(roomId); - foreach (var player in allPlayers) + // Remove the old host from players + await _roomPlayerRepository.DeleteAsync(roomPlayer.Id); + + _logger.LogInformation( + "Transferred host of room {RoomId} from {OldHostId} to {NewHostId}", + roomId, + userId, + newHost.UserId + ); + } + else { - await _roomPlayerRepository.DeleteAsync(player.Id); + // No other players - close the room + room.IsActive = false; + room.EndedAt = DateTime.UtcNow; + await _roomRepository.UpdateAsync(room); + + // Remove the host (only player) + await _roomPlayerRepository.DeleteAsync(roomPlayer.Id); + + _logger.LogInformation( + "Closed room {RoomId} as host {UserId} was the last player", + roomId, + userId + ); } await transaction.CommitAsync(); - _logger.LogInformation( - "Successfully closed room {RoomId} and removed {PlayerCount} players", - roomId, - allPlayers.Count() - ); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError( ex, - "Failed to close room {RoomId} when host {UserId} left", + "Failed to handle host {UserId} leaving room {RoomId}", roomId, userId ); @@ -520,13 +616,44 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, userId) } else { - // Regular player leaving - just remove them + // Regular player leaving - remove them and check if room is now empty _logger.LogInformation("Player {UserId} leaving room {RoomId}", userId, roomId); await _roomPlayerRepository.DeleteAsync(roomPlayer.Id); + + // Check if room is now empty + var remainingPlayers = await _roomPlayerRepository.GetByRoomIdAsync(roomId); + if (!remainingPlayers.Any()) + { + _logger.LogInformation("Room {RoomId} is now empty. Closing room.", roomId); + room.IsActive = false; + room.EndedAt = DateTime.UtcNow; + await _roomRepository.UpdateAsync(room); + } } - // Return the room (we already have it) - return MapToResponseDto(room); + // Refetch the room to get updated RoomPlayers collection + var updatedRoom = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room with ID {roomId} not found."); + + _logger.LogInformation( + "Broadcasting leave event for room {RoomId}. MaxPlayers: {MaxPlayers}, IsActive: {IsActive}", + roomId, + updatedRoom.MaxPlayers, + updatedRoom.IsActive + ); + + // Broadcast player left event via SSE + var roomDto = MapToResponseDto(updatedRoom); + _logger.LogInformation( + "RoomDTO to broadcast - MaxPlayers: {MaxPlayers}, IsActive: {IsActive}", + roomDto.MaxPlayers, + roomDto.IsActive + ); + await _roomSSEService.BroadcastEventAsync(roomId, "room_updated", roomDto); + + // Return the updated room + return roomDto; } // TODO: replace with automapper implementation diff --git a/Project.App/Project.Api/Services/SSEShutdownService.cs b/Project.App/Project.Api/Services/SSEShutdownService.cs new file mode 100644 index 0000000..292d4c4 --- /dev/null +++ b/Project.App/Project.Api/Services/SSEShutdownService.cs @@ -0,0 +1,32 @@ +using Project.Api.Services.Interface; + +namespace Project.Api.Services; + +/// +/// Background service that ensures SSE connections are closed gracefully on application shutdown. +/// +public class SSEShutdownService : IHostedService +{ + private readonly IRoomSSEService _roomSSEService; + private readonly ILogger _logger; + + public SSEShutdownService(IRoomSSEService roomSSEService, ILogger logger) + { + _roomSSEService = roomSSEService; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SSE Shutdown Service started"); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SSE Shutdown Service stopping - closing all SSE connections"); + await _roomSSEService.CloseAllConnectionsAsync(); + _logger.LogInformation("SSE Shutdown Service stopped"); + return; + } +} diff --git a/Project.App/Project.Api/Services/UserService.cs b/Project.App/Project.Api/Services/UserService.cs index 5d83daf..baaef45 100644 --- a/Project.App/Project.Api/Services/UserService.cs +++ b/Project.App/Project.Api/Services/UserService.cs @@ -117,7 +117,7 @@ public async Task UpdateUserBalanceAsync(Guid userId, double newBalance) } user.Balance = newBalance; - await _repo.UpdateBalanceAsync(user); + await _repo.UpdateAsync(user); return user; } catch (Exception e) diff --git a/Project.App/Project.Api/appsettings.Example.json b/Project.App/Project.Api/appsettings.Example.json new file mode 100644 index 0000000..527a28d --- /dev/null +++ b/Project.App/Project.Api/appsettings.Example.json @@ -0,0 +1,29 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=DauntingDevitosDB;User Id=sa;Password=YOUR_PASSWORD_HERE;TrustServerCertificate=True;" + }, + + "CorsSettings": { + "AllowedOrigins": ["http://localhost:3000", "https://yourfrontend.com"] + }, + + "CacheSettings": { + "DefaultExpirationMinutes": 30, + "EnableDistributedCache": false + }, + + "Google": { + "ClientId": "YOUR_GOOGLE_CLIENT_ID_HERE", + "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET_HERE" + } +} diff --git a/Project.App/Project.Api/start-db.sh b/Project.App/Project.Api/start-db.sh new file mode 100644 index 0000000..9bb8a58 --- /dev/null +++ b/Project.App/Project.Api/start-db.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Configuration +CONTAINER_NAME="mssql-express" +SA_PASSWORD="YourStrong@Passw0rd" +MSSQL_PORT=1433 + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting SQL Server Express in Docker...${NC}" + +# Check if container already exists +if [ "$(docker ps -a -q -f name=$CONTAINER_NAME)" ]; then + echo -e "${YELLOW}Container '$CONTAINER_NAME' already exists.${NC}" + + # Check if it's running + if [ "$(docker ps -q -f name=$CONTAINER_NAME)" ]; then + echo -e "${GREEN}Container is already running.${NC}" + else + echo -e "${YELLOW}Starting existing container...${NC}" + docker start $CONTAINER_NAME + echo -e "${GREEN}Container started successfully!${NC}" + fi +else + echo -e "${YELLOW}Creating new SQL Server container...${NC}" + + # Run new container + docker run -e "ACCEPT_EULA=Y" \ + -e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \ + -e "MSSQL_PID=Express" \ + -p $MSSQL_PORT:1433 \ + --name $CONTAINER_NAME \ + --hostname mssql-express \ + -d \ + mcr.microsoft.com/mssql/server:2022-latest + + if [ $? -eq 0 ]; then + echo -e "${GREEN}Container created and started successfully!${NC}" + echo -e "${YELLOW}Waiting for SQL Server to be ready...${NC}" + sleep 15 + else + echo -e "${RED}Failed to create container.${NC}" + exit 1 + fi +fi + +# Display connection information +echo "" +echo -e "${GREEN}==================================${NC}" +echo -e "${GREEN}SQL Server Express is running!${NC}" +echo -e "${GREEN}==================================${NC}" +echo -e "Server: ${YELLOW}localhost,$MSSQL_PORT${NC}" +echo -e "Username: ${YELLOW}sa${NC}" +echo -e "Password: ${YELLOW}$SA_PASSWORD${NC}" +echo -e "Connection String: ${YELLOW}Server=localhost,$MSSQL_PORT;User Id=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;${NC}" +echo "" +echo -e "To stop: ${YELLOW}docker stop $CONTAINER_NAME${NC}" +echo -e "To remove: ${YELLOW}docker rm $CONTAINER_NAME${NC}" +echo -e "To view logs: ${YELLOW}docker logs $CONTAINER_NAME${NC}" +echo "" \ No newline at end of file diff --git a/Project.App/Project.Client/next.config.mjs b/Project.App/Project.Client/next.config.mjs index 4713b39..7a34373 100644 --- a/Project.App/Project.Client/next.config.mjs +++ b/Project.App/Project.Client/next.config.mjs @@ -1,6 +1,15 @@ /** @type {import('next').NextConfig} */ const nextConfig = { // output: 'export', // Commented out to support dynamic routes like /player/[id] + + // Disable SSL certificate validation in development + // WARNING: Only use this in development, never in production + webpack: (config, { isServer }) => { + if (isServer) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + return config; + }, }; export default nextConfig; diff --git a/Project.App/Project.Client/package-lock.json b/Project.App/Project.Client/package-lock.json index e6afa5f..7bed0b4 100644 --- a/Project.App/Project.Client/package-lock.json +++ b/Project.App/Project.Client/package-lock.json @@ -18,13 +18,11 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "autoprefixer": "^10.4.21", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "oxlint": "^1.23.0", - "postcss": "^8.5.6", "prettier": "^3.6.2", - "tailwindcss": "^4.1.15" + "tailwindcss": "^4" } }, "node_modules/@adobe/css-tools": { @@ -99,7 +97,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -683,7 +680,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -707,7 +703,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1986,13 +1981,6 @@ "tailwindcss": "4.1.14" } }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.1.14", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", - "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/oxide": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz", @@ -2253,20 +2241,12 @@ "tailwindcss": "4.1.14" } }, - "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { - "version": "4.1.14", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", - "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2913,44 +2893,6 @@ "node": ">= 0.4" } }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -3110,7 +3052,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3740,20 +3681,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4793,7 +4720,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5425,16 +5351,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -5726,7 +5642,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5736,13 +5651,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -5819,7 +5727,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5829,7 +5736,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6323,9 +6229,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", - "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", "dev": true, "license": "MIT" }, diff --git a/Project.App/Project.Client/package.json b/Project.App/Project.Client/package.json index eac3d15..2b9af80 100644 --- a/Project.App/Project.Client/package.json +++ b/Project.App/Project.Client/package.json @@ -25,12 +25,10 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "autoprefixer": "^10.4.21", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "oxlint": "^1.23.0", - "postcss": "^8.5.6", "prettier": "^3.6.2", - "tailwindcss": "^4.1.15" + "tailwindcss": "^4" } } diff --git a/Project.App/Project.Client/src/app/components/AddCreditsModal.js b/Project.App/Project.Client/src/app/components/AddCreditsModal.js index bbdcd9a..97bcbf2 100644 --- a/Project.App/Project.Client/src/app/components/AddCreditsModal.js +++ b/Project.App/Project.Client/src/app/components/AddCreditsModal.js @@ -1,6 +1,6 @@ 'use client'; -export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd, setCreditsToAdd, onSubmit }) { +export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd, setCreditsToAdd, onSubmit, isLoading }) { if (!isOpen) return null; return ( @@ -23,11 +23,11 @@ export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd type="number" step="10" min="0" - max="200000" value={creditsToAdd} onChange={(e) => setCreditsToAdd(e.target.value)} placeholder="0" - className="w-full mt-1 px-4 py-3 bg-black/50 border-2 border-yellow-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500" + disabled={isLoading} + className="w-full mt-1 px-4 py-3 bg-black/50 border-2 border-yellow-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 disabled:opacity-50 disabled:cursor-not-allowed" required /> @@ -35,14 +35,16 @@ export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd
diff --git a/Project.App/Project.Client/src/app/components/CreateGameForm.js b/Project.App/Project.Client/src/app/components/CreateGameForm.js index f64f858..d837dca 100644 --- a/Project.App/Project.Client/src/app/components/CreateGameForm.js +++ b/Project.App/Project.Client/src/app/components/CreateGameForm.js @@ -1,34 +1,104 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; -export default function CreateGameForm() { - const [roomName, setRoomName] = useState(''); - const [minBet, setMinBet] = useState(''); - const [maxPlayers, setMaxPlayers] = useState(5); - const [roomNameError, setRoomNameError] = useState(''); +export default function CreateGameForm({ userId, onRoomCreated }) { + const router = useRouter(); const [roomDescription, setRoomDescription] = useState(''); + const [minBet, setMinBet] = useState('10'); + const [startingBalance, setStartingBalance] = useState('1000'); + const [maxPlayers, setMaxPlayers] = useState(5); + const [formError, setFormError] = useState(''); + const [isCreating, setIsCreating] = useState(false); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - // Validate roomName length - const name = roomName.trim(); - if (name.length > 20) { - setRoomNameError('Room name must be 20 characters or less.'); - const description = roomDescription.trim(); - if (description.length > 100) { - setRoomNameError('Room description must be 100 characters or less.'); - return; + + const description = roomDescription.trim(); + if (description.length > 500) { + setFormError('Room description must be 500 characters or less.'); + return; + } + + if (!description) { + setFormError('Room description is required.'); + return; + } + + const minBetValue = parseInt(minBet); + const startingBalanceValue = parseInt(startingBalance); + + if (minBetValue < 0) { + setFormError('Minimum bet cannot be negative.'); + return; + } + + if (minBetValue > startingBalanceValue) { + setFormError('Minimum bet cannot exceed starting balance.'); + return; + } + + setFormError(''); + setIsCreating(true); + + try { + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:7069'; + + // Create initial game state for Blackjack + const initialGameState = JSON.stringify({ + currentStage: { + $type: 'init' + }, + dealerHand: [] + }); + + // Create game config with BlackjackConfig structure + const gameConfig = JSON.stringify({ + startingBalance: startingBalanceValue, + minBet: minBetValue, + bettingTimeLimit: '00:01:00', // 1 minute + turnTimeLimit: '00:00:30' // 30 seconds + }); + + const response = await fetch(`${API_URL}/api/room`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + hostId: userId, + description: description, + isPublic: true, + gameMode: 'Blackjack', + gameState: initialGameState, + gameConfig: gameConfig, + maxPlayers: maxPlayers, + minPlayers: 2, + deckId: '', + }), + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Server error response:', error); + throw new Error(error.message || error.title || 'Failed to create room'); } + + const room = await response.json(); + + // Refresh the rooms list + if (onRoomCreated) { + onRoomCreated(); + } + + // Redirect to the game room immediately + router.push(`/game/${room.id}`); + } catch (error) { + console.error('Error creating room:', error); + setFormError(error.message); + setIsCreating(false); } - setRoomNameError(''); - - // Dummy submit - alert( - `Game created! Room: ${name} Description: ${roomDescription} (min bet: $${minBet}, max players: ${maxPlayers})`, - ); - setRoomName(''); - setMinBet(''); - setMaxPlayers(5); }; return ( @@ -39,52 +109,52 @@ export default function CreateGameForm() {

Create New Game

+ {formError && ( +
+ {formError} +
+ )}
- - Room Description (max 500 chars) +