├── .github └── workflows │ └── main_dating-app-course.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── API ├── API.csproj ├── API.http ├── Controllers │ ├── AccountController.cs │ ├── AdminController.cs │ ├── BaseApiController.cs │ ├── BuggyController.cs │ ├── FallbackController.cs │ ├── LikesController.cs │ ├── MessagesController.cs │ ├── UsersController.cs │ └── WeatherForecastController.cs ├── DTOs │ ├── CreateMessageDto.cs │ ├── LoginDto.cs │ ├── MemberDto.cs │ ├── MemberUpdateDto.cs │ ├── MessageDto.cs │ ├── PhotoDto.cs │ ├── PhotoForApprovalDto.cs │ ├── RegisterDto.cs │ └── UserDto.cs ├── Data │ ├── DataContext.cs │ ├── LikesRepository.cs │ ├── MessageRepository.cs │ ├── Migrations │ │ ├── 20240612073238_SqlInitial.Designer.cs │ │ ├── 20240612073238_SqlInitial.cs │ │ └── DataContextModelSnapshot.cs │ ├── PhotoRepository.cs │ ├── Seed.cs │ ├── UnitOfWork.cs │ ├── UserRepository.cs │ └── UserSeedData.json ├── Entities │ ├── AppRole.cs │ ├── AppUser.cs │ ├── AppUserRole.cs │ ├── Connection.cs │ ├── Group.cs │ ├── Message.cs │ ├── Photo.cs │ └── UserLike.cs ├── Errors │ └── ApiException.cs ├── Extensions │ ├── ApplicationServiceExtensions.cs │ ├── ClaimsPrincipleExtensions.cs │ ├── DateTimeExtensions.cs │ ├── HttpExtensions.cs │ └── IdentityServiceExtensions.cs ├── Helpers │ ├── AutoMapperProfiles.cs │ ├── CloudinarySettings.cs │ ├── LikesParams.cs │ ├── LogUserActivity.cs │ ├── MessageParams.cs │ ├── PagedList.cs │ ├── PaginationHeader.cs │ ├── PaginationParams.cs │ └── UserParams.cs ├── Interfaces │ ├── ILikesRepository.cs │ ├── IMessageRepository.cs │ ├── IPhotoRepository.cs │ ├── IPhotoService.cs │ ├── ITokenService.cs │ ├── IUnitOfWork.cs │ └── IUserRepository.cs ├── Middleware │ └── ExceptionMiddleware.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── PhotoService.cs │ └── TokenService.cs ├── SignalR │ ├── MessageHub.cs │ ├── PresenceHub.cs │ └── PresenceTracker.cs ├── WeatherForecast.cs ├── appsettings.Development.json └── wwwroot │ ├── 3rdpartylicenses.txt │ ├── assets │ └── user.png │ ├── favicon.ico │ ├── index.html │ ├── main-K3KICC6O.js │ ├── media │ ├── fontawesome-webfont-3KIJVIEY.svg │ ├── fontawesome-webfont-5GKVPAEF.woff2 │ ├── fontawesome-webfont-FMJ3VJ65.eot │ ├── fontawesome-webfont-RJ6LE7IU.ttf │ └── fontawesome-webfont-Z4ARLA73.woff │ ├── polyfills-S3BTP7ME.js │ └── styles-M6S37RWO.css ├── DatingApp.sln ├── client ├── .editorconfig ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── _directives │ │ │ └── has-role.directive.ts │ │ ├── _forms │ │ │ ├── date-picker │ │ │ │ ├── date-picker.component.css │ │ │ │ ├── date-picker.component.html │ │ │ │ └── date-picker.component.ts │ │ │ └── text-input │ │ │ │ ├── text-input.component.css │ │ │ │ ├── text-input.component.html │ │ │ │ └── text-input.component.ts │ │ ├── _guards │ │ │ ├── admin.guard.ts │ │ │ ├── auth.guard.ts │ │ │ └── prevent-unsaved-changes.guard.ts │ │ ├── _interceptors │ │ │ ├── error.interceptor.ts │ │ │ ├── jwt.interceptor.ts │ │ │ └── loading.interceptor.ts │ │ ├── _models │ │ │ ├── group.ts │ │ │ ├── member.ts │ │ │ ├── message.ts │ │ │ ├── pagination.ts │ │ │ ├── photo.ts │ │ │ ├── user.ts │ │ │ └── userParams.ts │ │ ├── _resolvers │ │ │ └── member-detailed.resolver.ts │ │ ├── _services │ │ │ ├── account.service.ts │ │ │ ├── admin.service.ts │ │ │ ├── busy.service.ts │ │ │ ├── confirm.service.ts │ │ │ ├── likes.service.ts │ │ │ ├── members.service.ts │ │ │ ├── message.service.ts │ │ │ ├── paginationHelper.ts │ │ │ └── presence.service.ts │ │ ├── admin │ │ │ ├── admin-panel │ │ │ │ ├── admin-panel.component.css │ │ │ │ ├── admin-panel.component.html │ │ │ │ └── admin-panel.component.ts │ │ │ ├── photo-management │ │ │ │ ├── photo-management.component.css │ │ │ │ ├── photo-management.component.html │ │ │ │ └── photo-management.component.ts │ │ │ └── user-management │ │ │ │ ├── user-management.component.css │ │ │ │ ├── user-management.component.html │ │ │ │ └── user-management.component.ts │ │ ├── app.component.css │ │ ├── app.component.html │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── errors │ │ │ ├── not-found │ │ │ │ ├── not-found.component.css │ │ │ │ ├── not-found.component.html │ │ │ │ └── not-found.component.ts │ │ │ ├── server-error │ │ │ │ ├── server-error.component.css │ │ │ │ ├── server-error.component.html │ │ │ │ └── server-error.component.ts │ │ │ └── test-errors │ │ │ │ ├── test-errors.component.css │ │ │ │ ├── test-errors.component.html │ │ │ │ └── test-errors.component.ts │ │ ├── home │ │ │ ├── home.component.css │ │ │ ├── home.component.html │ │ │ └── home.component.ts │ │ ├── lists │ │ │ ├── lists.component.css │ │ │ ├── lists.component.html │ │ │ └── lists.component.ts │ │ ├── members │ │ │ ├── member-card │ │ │ │ ├── member-card.component.css │ │ │ │ ├── member-card.component.html │ │ │ │ └── member-card.component.ts │ │ │ ├── member-detail │ │ │ │ ├── member-detail.component.css │ │ │ │ ├── member-detail.component.html │ │ │ │ └── member-detail.component.ts │ │ │ ├── member-edit │ │ │ │ ├── member-edit.component.css │ │ │ │ ├── member-edit.component.html │ │ │ │ └── member-edit.component.ts │ │ │ ├── member-list │ │ │ │ ├── member-list.component.css │ │ │ │ ├── member-list.component.html │ │ │ │ └── member-list.component.ts │ │ │ ├── member-messages │ │ │ │ ├── member-messages.component.css │ │ │ │ ├── member-messages.component.html │ │ │ │ └── member-messages.component.ts │ │ │ └── photo-editor │ │ │ │ ├── photo-editor.component.css │ │ │ │ ├── photo-editor.component.html │ │ │ │ └── photo-editor.component.ts │ │ ├── messages │ │ │ ├── messages.component.css │ │ │ ├── messages.component.html │ │ │ └── messages.component.ts │ │ ├── modals │ │ │ ├── confirm-dialog │ │ │ │ ├── confirm-dialog.component.css │ │ │ │ ├── confirm-dialog.component.html │ │ │ │ └── confirm-dialog.component.ts │ │ │ └── roles-modal │ │ │ │ ├── roles-modal.component.css │ │ │ │ ├── roles-modal.component.html │ │ │ │ └── roles-modal.component.ts │ │ ├── nav │ │ │ ├── nav.component.css │ │ │ ├── nav.component.html │ │ │ └── nav.component.ts │ │ └── register │ │ │ ├── register.component.css │ │ │ ├── register.component.html │ │ │ └── register.component.ts │ ├── assets │ │ ├── .gitkeep │ │ └── user.png │ ├── environments │ │ ├── environment.development.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ └── styles.css ├── ssl │ ├── localhost-key.pem │ └── localhost.pem ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json └── docker-compose.yml /.github/workflows/main_dating-app-course.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy ASP.Net Core app to Azure Web App - dating-app-course 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: windows-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '20' 23 | 24 | - name: Install Angular CLI 25 | run: npm install -g @angular/cli@17 26 | 27 | - name: Install deps and build angular app 28 | run: | 29 | cd client 30 | npm install 31 | ng build 32 | 33 | - name: Set up .NET Core 34 | uses: actions/setup-dotnet@v1 35 | with: 36 | dotnet-version: '8.x' 37 | include-prerelease: true 38 | 39 | - name: Build with dotnet 40 | run: dotnet build --configuration Release 41 | 42 | - name: dotnet publish 43 | run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp 44 | 45 | - name: Upload artifact for deployment job 46 | uses: actions/upload-artifact@v3 47 | with: 48 | name: .net-app 49 | path: ${{env.DOTNET_ROOT}}/myapp 50 | 51 | deploy: 52 | runs-on: windows-latest 53 | needs: build 54 | environment: 55 | name: 'Production' 56 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 57 | permissions: 58 | id-token: write #This is required for requesting the JWT 59 | 60 | steps: 61 | - name: Download artifact from build job 62 | uses: actions/download-artifact@v3 63 | with: 64 | name: .net-app 65 | 66 | - name: Login to Azure 67 | uses: azure/login@v1 68 | with: 69 | client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_FA13CA60E67449A5960646416D19E3C1 }} 70 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_916EAEF7A6A74249A0E49B1E04C0064D }} 71 | subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_1D21C2FC26244DD6A2C6873AF75CF3DC }} 72 | 73 | - name: Deploy to Azure Web App 74 | id: deploy-to-webapp 75 | uses: azure/webapps-deploy@v2 76 | with: 77 | app-name: 'dating-app-course' 78 | slot-name: 'Production' 79 | package: . 80 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "C#: API Debug", 9 | "type": "dotnet", 10 | "request": "launch", 11 | "projectPath": "${workspaceFolder}/API.csproj" 12 | }, 13 | { 14 | "name": ".NET Core Attach", 15 | "type": "coreclr", 16 | "request": "attach" 17 | } 18 | 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /API/API.http: -------------------------------------------------------------------------------- 1 | @API_HostAddress = http://localhost:5025 2 | 3 | GET {{API_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | -------------------------------------------------------------------------------- /API/Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using System.Text; 3 | using API.Data; 4 | using API.DTOs; 5 | using API.Entities; 6 | using AutoMapper; 7 | using Microsoft.AspNetCore.Identity; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace API.Controllers; 12 | 13 | public class AccountController(UserManager userManager, ITokenService tokenService, 14 | IMapper mapper) : BaseApiController 15 | { 16 | [HttpPost("register")] // account/register 17 | public async Task> Register(RegisterDto registerDto) 18 | { 19 | if (await UserExists(registerDto.Username)) return BadRequest("Username is taken"); 20 | 21 | var user = mapper.Map(registerDto); 22 | 23 | user.UserName = registerDto.Username.ToLower(); 24 | 25 | var result = await userManager.CreateAsync(user, registerDto.Password); 26 | 27 | if (!result.Succeeded) return BadRequest(result.Errors); 28 | 29 | return new UserDto 30 | { 31 | Username = user.UserName, 32 | Token = await tokenService.CreateToken(user), 33 | KnownAs = user.KnownAs, 34 | Gender = user.Gender 35 | }; 36 | } 37 | 38 | [HttpPost("login")] 39 | public async Task> Login(LoginDto loginDto) 40 | { 41 | var user = await userManager.Users 42 | .Include(p => p.Photos) 43 | .FirstOrDefaultAsync(x => 44 | x.NormalizedUserName == loginDto.Username.ToUpper()); 45 | 46 | if (user == null || user.UserName == null) return Unauthorized("Invalid username"); 47 | 48 | return new UserDto 49 | { 50 | Username = user.UserName, 51 | KnownAs = user.KnownAs, 52 | Token = await tokenService.CreateToken(user), 53 | Gender = user.Gender, 54 | PhotoUrl = user.Photos.FirstOrDefault(x => x.IsMain)?.Url 55 | }; 56 | } 57 | 58 | private async Task UserExists(string username) 59 | { 60 | return await userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper()); // Bob != bob 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /API/Controllers/AdminController.cs: -------------------------------------------------------------------------------- 1 | using API.Controllers; 2 | using API.Entities; 3 | using API.Interfaces; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Identity; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace API; 10 | 11 | public class AdminController(UserManager userManager, IUnitOfWork unitOfWork, 12 | IPhotoService photoService) : BaseApiController 13 | { 14 | [Authorize(Policy = "RequireAdminRole")] 15 | [HttpGet("users-with-roles")] 16 | public async Task GetUsersWithRoles() 17 | { 18 | var users = await userManager.Users 19 | .OrderBy(x => x.UserName) 20 | .Select(x => new 21 | { 22 | x.Id, 23 | Username = x.UserName, 24 | Roles = x.UserRoles.Select(r => r.Role.Name).ToList() 25 | }).ToListAsync(); 26 | 27 | return Ok(users); 28 | } 29 | 30 | [Authorize(Policy = "RequireAdminRole")] 31 | [HttpPost("edit-roles/{username}")] 32 | public async Task EditRoles(string username, string roles) 33 | { 34 | if (string.IsNullOrEmpty(roles)) return BadRequest("you must select at least one role"); 35 | 36 | var selectedRoles = roles.Split(",").ToArray(); 37 | 38 | var user = await userManager.FindByNameAsync(username); 39 | 40 | if (user == null) return BadRequest("User not found"); 41 | 42 | var userRoles = await userManager.GetRolesAsync(user); 43 | 44 | var result = await userManager.AddToRolesAsync(user, selectedRoles.Except(userRoles)); 45 | 46 | if (!result.Succeeded) return BadRequest("Failed to add to roles"); 47 | 48 | result = await userManager.RemoveFromRolesAsync(user, userRoles.Except(selectedRoles)); 49 | 50 | if (!result.Succeeded) return BadRequest("Failed to remove from roles"); 51 | 52 | return Ok(await userManager.GetRolesAsync(user)); 53 | } 54 | 55 | [Authorize(Policy = "ModeratePhotoRole")] 56 | [HttpGet("photos-to-moderate")] 57 | public async Task GetPhotosForModeration() 58 | { 59 | var photos = await unitOfWork.PhotoRepository.GetUnapprovedPhotos(); 60 | 61 | return Ok(photos); 62 | } 63 | 64 | [Authorize(Policy = "ModeratePhotoRole")] 65 | [HttpPost("approve-photo/{photoId}")] 66 | public async Task ApprovePhoto(int photoId) 67 | { 68 | var photo = await unitOfWork.PhotoRepository.GetPhotoById(photoId); 69 | 70 | if (photo == null) return BadRequest("Could not get photo from db"); 71 | 72 | photo.IsApproved = true; 73 | 74 | var user = await unitOfWork.UserRepository.GetUserByPhotoId(photoId); 75 | 76 | if (user == null) return BadRequest("Could not get user from db"); 77 | 78 | if (!user.Photos.Any(x => x.IsMain)) photo.IsMain = true; 79 | 80 | await unitOfWork.Complete(); 81 | 82 | return Ok(); 83 | } 84 | 85 | [Authorize(Policy = "ModeratePhotoRole")] 86 | [HttpPost("reject-photo/{photoId}")] 87 | public async Task RejectPhoto(int photoId) 88 | { 89 | var photo = await unitOfWork.PhotoRepository.GetPhotoById(photoId); 90 | 91 | if (photo == null) return BadRequest("Could not get photo from db"); 92 | 93 | if (photo.PublicId != null) 94 | { 95 | var result = await photoService.DeletePhotoAsync(photo.PublicId); 96 | 97 | if (result.Result == "ok") 98 | { 99 | unitOfWork.PhotoRepository.RemovePhoto(photo); 100 | } 101 | } 102 | else 103 | { 104 | unitOfWork.PhotoRepository.RemovePhoto(photo); 105 | } 106 | 107 | await unitOfWork.Complete(); 108 | 109 | return Ok(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | [ServiceFilter(typeof(LogUserActivity))] 6 | [ApiController] 7 | [Route("api/[controller]")] 8 | public class BaseApiController : ControllerBase 9 | { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /API/Controllers/BuggyController.cs: -------------------------------------------------------------------------------- 1 | using API.Controllers; 2 | using API.Data; 3 | using API.Entities; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace API; 8 | 9 | public class BuggyController(DataContext context) : BaseApiController 10 | { 11 | [Authorize] 12 | [HttpGet("auth")] 13 | public ActionResult GetAuth() 14 | { 15 | return "secret text"; 16 | } 17 | 18 | [HttpGet("not-found")] 19 | public ActionResult GetNotFound() 20 | { 21 | var thing = context.Users.Find(-1); 22 | 23 | if (thing == null) return NotFound(); 24 | 25 | return thing; 26 | } 27 | 28 | [HttpGet("server-error")] 29 | public ActionResult GetServerError() 30 | { 31 | var thing = context.Users.Find(-1) ?? throw new Exception("A bad thing has happened"); 32 | 33 | return thing; 34 | } 35 | 36 | [HttpGet("bad-request")] 37 | public ActionResult GetBadRequest() 38 | { 39 | return BadRequest("This was not a good request"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /API/Controllers/FallbackController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | public class FallbackController : Controller 6 | { 7 | public ActionResult Index() 8 | { 9 | return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), 10 | "wwwroot", "index.html"), "text/HTML"); 11 | } 12 | } -------------------------------------------------------------------------------- /API/Controllers/LikesController.cs: -------------------------------------------------------------------------------- 1 | using API.Controllers; 2 | using API.DTOs; 3 | using API.Extensions; 4 | using API.Helpers; 5 | using API.Interfaces; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace API; 9 | 10 | public class LikesController(IUnitOfWork unitOfWork) : BaseApiController 11 | { 12 | [HttpPost("{targetUserId:int}")] 13 | public async Task ToggleLike(int targetUserId) 14 | { 15 | var sourceUserId = User.GetUserId(); 16 | 17 | if (sourceUserId == targetUserId) return BadRequest("You cannot like yourself"); 18 | 19 | var existingLike = await unitOfWork.LikesRepository.GetUserLike(sourceUserId, targetUserId); 20 | 21 | if (existingLike == null) 22 | { 23 | var like = new UserLike 24 | { 25 | SourceUserId = sourceUserId, 26 | TargetUserId = targetUserId 27 | }; 28 | 29 | unitOfWork.LikesRepository.AddLike(like); 30 | } 31 | else 32 | { 33 | unitOfWork.LikesRepository.DeleteLike(existingLike); 34 | } 35 | 36 | if (await unitOfWork.Complete()) return Ok(); 37 | 38 | return BadRequest("Failed to update like"); 39 | } 40 | 41 | [HttpGet("list")] 42 | public async Task>> GetCurrentUserLikeIds() 43 | { 44 | return Ok(await unitOfWork.LikesRepository.GetCurrentUserLikeIds(User.GetUserId())); 45 | } 46 | 47 | [HttpGet] 48 | public async Task>> GetUserLikes([FromQuery]LikesParams likesParams) 49 | { 50 | likesParams.UserId = User.GetUserId(); 51 | var users = await unitOfWork.LikesRepository.GetUserLikes(likesParams); 52 | 53 | Response.AddPaginationHeader(users); 54 | 55 | return Ok(users); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /API/Controllers/MessagesController.cs: -------------------------------------------------------------------------------- 1 | using API.Controllers; 2 | using API.DTOs; 3 | using API.Extensions; 4 | using API.Interfaces; 5 | using AutoMapper; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace API; 10 | 11 | [Authorize] 12 | public class MessagesController(IUnitOfWork unitOfWork, 13 | IMapper mapper) : BaseApiController 14 | { 15 | [HttpPost] 16 | public async Task> CreateMessage(CreateMessageDto createMessageDto) 17 | { 18 | var username = User.GetUsername(); 19 | 20 | if (username == createMessageDto.RecipientUsername.ToLower()) 21 | return BadRequest("You cannot message yourself"); 22 | 23 | var sender = await unitOfWork.UserRepository.GetUserByUsernameAsync(username); 24 | var recipient = await unitOfWork.UserRepository.GetUserByUsernameAsync(createMessageDto.RecipientUsername); 25 | 26 | if (recipient == null || sender == null || sender.UserName == null || recipient.UserName == null) 27 | return BadRequest("Cannot send message at this time"); 28 | 29 | var message = new Message 30 | { 31 | Sender = sender, 32 | Recipient = recipient, 33 | SenderUsername = sender.UserName, 34 | RecipientUsername = recipient.UserName, 35 | Content = createMessageDto.Content 36 | }; 37 | 38 | unitOfWork.MessageRepository.AddMessage(message); 39 | 40 | if (await unitOfWork.Complete()) return Ok(mapper.Map(message)); 41 | 42 | return BadRequest("Failed to save message"); 43 | } 44 | 45 | [HttpGet] 46 | public async Task>> GetMessagesForUser( 47 | [FromQuery]MessageParams messageParams) 48 | { 49 | messageParams.Username = User.GetUsername(); 50 | 51 | var messages = await unitOfWork.MessageRepository.GetMessagesForUser(messageParams); 52 | 53 | Response.AddPaginationHeader(messages); 54 | 55 | return messages; 56 | } 57 | 58 | [HttpGet("thread/{username}")] 59 | public async Task>> GetMessageThread(string username) 60 | { 61 | var currentUsername = User.GetUsername(); 62 | 63 | return Ok(await unitOfWork.MessageRepository.GetMessageThread(currentUsername, username)); 64 | } 65 | 66 | [HttpDelete("{id}")] 67 | public async Task DeleteMessage(int id) 68 | { 69 | var username = User.GetUsername(); 70 | 71 | var message = await unitOfWork.MessageRepository.GetMessage(id); 72 | 73 | if (message == null) return BadRequest("Cannot delete this message"); 74 | 75 | if (message.SenderUsername != username && message.RecipientUsername != username) 76 | return Forbid(); 77 | 78 | if (message.SenderUsername == username) message.SenderDeleted = true; 79 | if (message.RecipientUsername == username) message.RecipientDeleted = true; 80 | 81 | if (message is {SenderDeleted: true, RecipientDeleted: true}) { 82 | unitOfWork.MessageRepository.DeleteMessage(message); 83 | } 84 | 85 | if (await unitOfWork.Complete()) return Ok(); 86 | 87 | return BadRequest("Problem deleting the message"); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /API/Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using API.DTOs; 3 | using API.Entities; 4 | using API.Extensions; 5 | using API.Helpers; 6 | using API.Interfaces; 7 | using AutoMapper; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Mvc; 10 | 11 | namespace API.Controllers; 12 | 13 | [Authorize] 14 | public class UsersController(IUnitOfWork unitOfWork, IMapper mapper, 15 | IPhotoService photoService) : BaseApiController 16 | { 17 | [HttpGet] 18 | public async Task>> GetUsers([FromQuery]UserParams userParams) 19 | { 20 | userParams.CurrentUsername = User.GetUsername(); 21 | var users = await unitOfWork.UserRepository.GetMembersAsync(userParams); 22 | 23 | Response.AddPaginationHeader(users); 24 | 25 | return Ok(users); 26 | } 27 | 28 | [HttpGet("{username}")] // /api/users/2 29 | public async Task> GetUser(string username) 30 | { 31 | var currentUsername = User.GetUsername(); 32 | var user = await unitOfWork.UserRepository.GetMemberAsync(username, 33 | isCurrentUser: currentUsername == username); 34 | 35 | if (user == null) return NotFound(); 36 | 37 | return user; 38 | } 39 | 40 | [HttpPut] 41 | public async Task UpdateUser(MemberUpdateDto memberUpdateDto) 42 | { 43 | var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 44 | 45 | if (user == null) return BadRequest("Could not find user"); 46 | 47 | mapper.Map(memberUpdateDto, user); 48 | 49 | if (await unitOfWork.Complete()) return NoContent(); 50 | 51 | return BadRequest("Failed to update the user"); 52 | } 53 | 54 | [HttpPost("add-photo")] 55 | public async Task> AddPhoto(IFormFile file) 56 | { 57 | var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 58 | 59 | if (user == null) return BadRequest("Cannot update user"); 60 | 61 | var result = await photoService.AddPhotoAsync(file); 62 | 63 | if (result.Error != null) return BadRequest(result.Error.Message); 64 | 65 | var photo = new Photo 66 | { 67 | Url = result.SecureUrl.AbsoluteUri, 68 | PublicId = result.PublicId 69 | }; 70 | 71 | user.Photos.Add(photo); 72 | 73 | if (await unitOfWork.Complete()) 74 | return CreatedAtAction(nameof(GetUser), 75 | new {username = user.UserName}, mapper.Map(photo)); 76 | 77 | return BadRequest("Problem adding photo"); 78 | } 79 | 80 | [HttpPut("set-main-photo/{photoId:int}")] 81 | public async Task SetMainPhoto(int photoId) 82 | { 83 | var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 84 | 85 | if (user == null) return BadRequest("Could not find user"); 86 | 87 | var photo = user.Photos.FirstOrDefault(x => x.Id == photoId); 88 | 89 | if (photo == null || photo.IsMain) return BadRequest("Cannot use this as main photo"); 90 | 91 | var currentMain = user.Photos.FirstOrDefault(x => x.IsMain); 92 | if (currentMain != null) currentMain.IsMain = false; 93 | photo.IsMain = true; 94 | 95 | if (await unitOfWork.Complete()) return NoContent(); 96 | 97 | return BadRequest("Problem setting main photo"); 98 | } 99 | 100 | [HttpDelete("delete-photo/{photoId:int}")] 101 | public async Task DeletePhoto(int photoId) 102 | { 103 | var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 104 | 105 | if (user == null) return BadRequest("User not found"); 106 | 107 | var photo = await unitOfWork.PhotoRepository.GetPhotoById(photoId); 108 | 109 | if (photo == null || photo.IsMain) return BadRequest("This photo cannot be deleted"); 110 | 111 | if (photo.PublicId != null) 112 | { 113 | var result = await photoService.DeletePhotoAsync(photo.PublicId); 114 | if (result.Error != null) return BadRequest(result.Error.Message); 115 | } 116 | 117 | user.Photos.Remove(photo); 118 | 119 | if (await unitOfWork.Complete()) return Ok(); 120 | 121 | return BadRequest("Problem deleting photo"); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /API/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | public class WeatherForecastController : BaseApiController 6 | { 7 | private static readonly string[] Summaries = new[] 8 | { 9 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 10 | }; 11 | 12 | private readonly ILogger _logger; 13 | 14 | public WeatherForecastController(ILogger logger) 15 | { 16 | _logger = logger; 17 | } 18 | 19 | [HttpGet(Name = "GetWeatherForecast")] 20 | public IEnumerable Get() 21 | { 22 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 23 | { 24 | Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), 25 | TemperatureC = Random.Shared.Next(-20, 55), 26 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 27 | }) 28 | .ToArray(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /API/DTOs/CreateMessageDto.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class CreateMessageDto 4 | { 5 | public required string RecipientUsername { get; set; } 6 | public required string Content { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /API/DTOs/LoginDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class LoginDto 4 | { 5 | public required string Username { get; set; } 6 | public required string Password { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /API/DTOs/MemberDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class MemberDto 4 | { 5 | public int Id { get; set; } 6 | public string? Username { get; set; } 7 | public int Age { get; set; } 8 | public string? PhotoUrl { get; set; } 9 | public string? KnownAs { get; set; } 10 | public DateTime Created { get; set; } 11 | public DateTime LastActive { get; set; } 12 | public string? Gender { get; set; } 13 | public string? Introduction { get; set; } 14 | public string? Interests { get; set; } 15 | public string? LookingFor { get; set; } 16 | public string? City { get; set; } 17 | public string? Country { get; set; } 18 | public List? Photos { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /API/DTOs/MemberUpdateDto.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class MemberUpdateDto 4 | { 5 | public string? Introduction { get; set; } 6 | public string? LookingFor { get; set; } 7 | public string? Interests { get; set; } 8 | public string? City { get; set; } 9 | public string? Country { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /API/DTOs/MessageDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class MessageDto 4 | { 5 | public int Id { get; set; } 6 | public int SenderId { get; set; } 7 | public required string SenderUsername { get; set; } 8 | public required string SenderPhotoUrl { get; set; } 9 | public int RecipientId { get; set; } 10 | public required string RecipientUsername { get; set; } 11 | public required string RecipientPhotoUrl { get; set; } 12 | public required string Content { get; set; } 13 | public DateTime? DateRead { get; set; } 14 | public DateTime MessageSent { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /API/DTOs/PhotoDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class PhotoDto 4 | { 5 | public int Id { get; set; } 6 | public string? Url { get; set; } 7 | public bool IsMain { get; set; } 8 | public bool IsApproved { get; set; } 9 | } -------------------------------------------------------------------------------- /API/DTOs/PhotoForApprovalDto.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class PhotoForApprovalDto 4 | { 5 | public int Id { get; set; } 6 | public required string Url { get; set; } 7 | public string? Username { get; set; } // optional as this matches the AppUser entity prop 8 | public bool IsApproved { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /API/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace API; 4 | 5 | public class RegisterDto 6 | { 7 | [Required] 8 | public string Username { get; set; } = string.Empty; 9 | 10 | [Required] public string? KnownAs { get; set; } 11 | [Required] public string? Gender { get; set; } 12 | [Required] public string? DateOfBirth { get; set; } 13 | [Required] public string? City { get; set; } 14 | [Required] public string? Country { get; set; } 15 | 16 | [Required] 17 | [StringLength(8, MinimumLength = 4)] 18 | public string Password { get; set; } = string.Empty; 19 | } -------------------------------------------------------------------------------- /API/DTOs/UserDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class UserDto 4 | { 5 | public required string Username { get; set; } 6 | public required string KnownAs { get; set; } 7 | public required string Token { get; set; } 8 | public required string Gender { get; set; } 9 | public string? PhotoUrl { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /API/Data/DataContext.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace API.Data; 7 | 8 | public class DataContext(DbContextOptions options) : IdentityDbContext, AppUserRole, IdentityUserLogin, IdentityRoleClaim, 10 | IdentityUserToken>(options) 11 | { 12 | public DbSet Likes { get; set; } 13 | public DbSet Messages { get; set; } 14 | public DbSet Groups { get; set; } 15 | public DbSet Connections { get; set; } 16 | public DbSet Photos { get; set; } 17 | 18 | protected override void OnModelCreating(ModelBuilder builder) 19 | { 20 | base.OnModelCreating(builder); 21 | 22 | builder.Entity() 23 | .HasMany(ur => ur.UserRoles) 24 | .WithOne(u => u.User) 25 | .HasForeignKey(ur => ur.UserId) 26 | .IsRequired(); 27 | 28 | builder.Entity() 29 | .HasMany(ur => ur.UserRoles) 30 | .WithOne(u => u.Role) 31 | .HasForeignKey(ur => ur.RoleId) 32 | .IsRequired(); 33 | 34 | builder.Entity() 35 | .HasKey(k => new { k.SourceUserId, k.TargetUserId }); 36 | 37 | builder.Entity() 38 | .HasOne(s => s.SourceUser) 39 | .WithMany(l => l.LikedUsers) 40 | .HasForeignKey(s => s.SourceUserId) 41 | .OnDelete(DeleteBehavior.Cascade); 42 | 43 | builder.Entity() 44 | .HasOne(s => s.TargetUser) 45 | .WithMany(l => l.LikedByUsers) 46 | .HasForeignKey(s => s.TargetUserId) 47 | .OnDelete(DeleteBehavior.NoAction); 48 | 49 | builder.Entity() 50 | .HasOne(x => x.Recipient) 51 | .WithMany(x => x.MessagesReceived) 52 | .OnDelete(DeleteBehavior.Restrict); 53 | 54 | builder.Entity() 55 | .HasOne(x => x.Sender) 56 | .WithMany(x => x.MessagesSent) 57 | .OnDelete(DeleteBehavior.Restrict); 58 | 59 | builder.Entity().HasQueryFilter(p => p.IsApproved); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /API/Data/LikesRepository.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.DTOs; 3 | using API.Helpers; 4 | using AutoMapper; 5 | using AutoMapper.QueryableExtensions; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace API; 9 | 10 | public class LikesRepository(DataContext context, IMapper mapper) : ILikesRepository 11 | { 12 | public void AddLike(UserLike like) 13 | { 14 | context.Likes.Add(like); 15 | } 16 | 17 | public void DeleteLike(UserLike like) 18 | { 19 | context.Likes.Remove(like); 20 | } 21 | 22 | public async Task> GetCurrentUserLikeIds(int currentUserId) 23 | { 24 | return await context.Likes 25 | .Where(x => x.SourceUserId == currentUserId) 26 | .Select(x => x.TargetUserId) 27 | .ToListAsync(); 28 | } 29 | 30 | public async Task GetUserLike(int sourceUserId, int targetUserId) 31 | { 32 | return await context.Likes.FindAsync(sourceUserId, targetUserId); 33 | } 34 | 35 | public async Task> GetUserLikes(LikesParams likesParams) 36 | { 37 | var likes = context.Likes.AsQueryable(); 38 | IQueryable query; 39 | 40 | switch (likesParams.Predicate) 41 | { 42 | case "liked": 43 | query = likes 44 | .Where(x => x.SourceUserId == likesParams.UserId) 45 | .Select(x => x.TargetUser) 46 | .ProjectTo(mapper.ConfigurationProvider); 47 | break; 48 | case "likedBy": 49 | query = likes 50 | .Where(x => x.TargetUserId == likesParams.UserId) 51 | .Select(x => x.SourceUser) 52 | .ProjectTo(mapper.ConfigurationProvider); 53 | break; 54 | default: 55 | var likeIds = await GetCurrentUserLikeIds(likesParams.UserId); 56 | 57 | query = likes 58 | .Where(x => x.TargetUserId == likesParams.UserId && likeIds.Contains(x.SourceUserId)) 59 | .Select(x => x.SourceUser) 60 | .ProjectTo(mapper.ConfigurationProvider); 61 | break; 62 | } 63 | 64 | return await PagedList.CreateAsync(query, likesParams.PageNumber, likesParams.PageSize); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /API/Data/MessageRepository.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.DTOs; 3 | using API.Entities; 4 | using API.Helpers; 5 | using API.Interfaces; 6 | using AutoMapper; 7 | using AutoMapper.QueryableExtensions; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace API; 11 | 12 | public class MessageRepository(DataContext context, IMapper mapper) : IMessageRepository 13 | { 14 | public void AddGroup(Group group) 15 | { 16 | context.Groups.Add(group); 17 | } 18 | 19 | public void AddMessage(Message message) 20 | { 21 | context.Messages.Add(message); 22 | } 23 | 24 | public void DeleteMessage(Message message) 25 | { 26 | context.Messages.Remove(message); 27 | } 28 | 29 | public async Task GetConnection(string connectionId) 30 | { 31 | return await context.Connections.FindAsync(connectionId); 32 | } 33 | 34 | public async Task GetGroupForConnection(string connectionId) 35 | { 36 | return await context.Groups 37 | .Include(x => x.Connections) 38 | .Where(x => x.Connections.Any(c => c.ConnectionId == connectionId)) 39 | .FirstOrDefaultAsync(); 40 | } 41 | 42 | public async Task GetMessage(int id) 43 | { 44 | return await context.Messages.FindAsync(id); 45 | } 46 | 47 | public async Task GetMessageGroup(string groupName) 48 | { 49 | return await context.Groups 50 | .Include(x => x.Connections) 51 | .FirstOrDefaultAsync(x => x.Name == groupName); 52 | } 53 | 54 | public async Task> GetMessagesForUser(MessageParams messageParams) 55 | { 56 | var query = context.Messages 57 | .OrderByDescending(x => x.MessageSent) 58 | .AsQueryable(); 59 | 60 | query = messageParams.Container switch 61 | { 62 | "Inbox" => query.Where(x => x.Recipient.UserName == messageParams.Username 63 | && x.RecipientDeleted == false), 64 | "Outbox" => query.Where(x => x.Sender.UserName == messageParams.Username 65 | && x.SenderDeleted == false), 66 | _ => query.Where(x => x.Recipient.UserName == messageParams.Username && x.DateRead == null 67 | && x.RecipientDeleted == false) 68 | }; 69 | 70 | var messages = query.ProjectTo(mapper.ConfigurationProvider); 71 | 72 | return await PagedList.CreateAsync(messages, messageParams.PageNumber, 73 | messageParams.PageSize); 74 | } 75 | 76 | public async Task> GetMessageThread(string currentUsername, string recipientUsername) 77 | { 78 | var query = context.Messages 79 | .Where(x => 80 | x.RecipientUsername == currentUsername 81 | && x.RecipientDeleted == false 82 | && x.SenderUsername == recipientUsername || 83 | x.SenderUsername == currentUsername 84 | && x.SenderDeleted == false 85 | && x.RecipientUsername == recipientUsername 86 | ) 87 | .OrderBy(x => x.MessageSent) 88 | .AsQueryable(); 89 | 90 | var unreadMessages = query.Where(x => x.DateRead == null && 91 | x.RecipientUsername == currentUsername).ToList(); 92 | 93 | if (unreadMessages.Count != 0) 94 | { 95 | unreadMessages.ForEach(x => x.DateRead = DateTime.UtcNow); 96 | } 97 | 98 | return await query.ProjectTo(mapper.ConfigurationProvider).ToListAsync(); 99 | } 100 | 101 | public void RemoveConnection(Connection connection) 102 | { 103 | context.Connections.Remove(connection); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /API/Data/PhotoRepository.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | using API.Interfaces; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace API.Data; 6 | 7 | public class PhotoRepository(DataContext context) : IPhotoRepository 8 | { 9 | public async Task GetPhotoById(int id) 10 | { 11 | return await context.Photos 12 | .IgnoreQueryFilters() 13 | .SingleOrDefaultAsync(x => x.Id == id); 14 | } 15 | 16 | public async Task> GetUnapprovedPhotos() 17 | { 18 | return await context.Photos 19 | .IgnoreQueryFilters() 20 | .Where(p => p.IsApproved == false) 21 | .Select(u => new PhotoForApprovalDto 22 | { 23 | Id = u.Id, 24 | Username = u.AppUser.UserName, 25 | Url = u.Url, 26 | IsApproved = u.IsApproved 27 | }).ToListAsync(); 28 | } 29 | 30 | public void RemovePhoto(Photo photo) 31 | { 32 | context.Photos.Remove(photo); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /API/Data/Seed.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using System.Text; 3 | using System.Text.Json; 4 | using API.Entities; 5 | using Microsoft.AspNetCore.Identity; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace API.Data; 9 | 10 | public class Seed 11 | { 12 | public static async Task SeedUsers(UserManager userManager, RoleManager roleManager) 13 | { 14 | if (await userManager.Users.AnyAsync()) return; 15 | 16 | var userData = await File.ReadAllTextAsync("Data/UserSeedData.json"); 17 | 18 | var options = new JsonSerializerOptions{PropertyNameCaseInsensitive = true}; 19 | 20 | var users = JsonSerializer.Deserialize>(userData, options); 21 | 22 | if (users == null) return; 23 | 24 | var roles = new List 25 | { 26 | new() {Name = "Member"}, 27 | new() {Name = "Admin"}, 28 | new() {Name = "Moderator"}, 29 | }; 30 | 31 | foreach (var role in roles) 32 | { 33 | await roleManager.CreateAsync(role); 34 | } 35 | 36 | foreach (var user in users) 37 | { 38 | user.Photos.First().IsApproved = true; 39 | user.UserName = user.UserName!.ToLower(); 40 | await userManager.CreateAsync(user, "Pa$$w0rd"); 41 | await userManager.AddToRoleAsync(user, "Member"); 42 | } 43 | 44 | var admin = new AppUser 45 | { 46 | UserName = "admin", 47 | KnownAs = "Admin", 48 | Gender = "", 49 | City = "", 50 | Country = "" 51 | }; 52 | 53 | await userManager.CreateAsync(admin, "Pa$$w0rd"); 54 | await userManager.AddToRolesAsync(admin, ["Admin", "Moderator"]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /API/Data/UnitOfWork.cs: -------------------------------------------------------------------------------- 1 | using API.Interfaces; 2 | 3 | namespace API.Data; 4 | 5 | public class UnitOfWork(DataContext context, IUserRepository userRepository, 6 | ILikesRepository likesRepository, IMessageRepository messageRepository, 7 | IPhotoRepository photoRepository) : IUnitOfWork 8 | { 9 | public IUserRepository UserRepository => userRepository; 10 | 11 | public IMessageRepository MessageRepository => messageRepository; 12 | 13 | public ILikesRepository LikesRepository => likesRepository; 14 | public IPhotoRepository PhotoRepository => photoRepository; 15 | 16 | public async Task Complete() 17 | { 18 | return await context.SaveChangesAsync() > 0; 19 | } 20 | 21 | public bool HasChanges() 22 | { 23 | return context.ChangeTracker.HasChanges(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /API/Data/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.DTOs; 3 | using API.Entities; 4 | using API.Helpers; 5 | using AutoMapper; 6 | using AutoMapper.QueryableExtensions; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace API; 10 | 11 | public class UserRepository(DataContext context, IMapper mapper) : IUserRepository 12 | { 13 | public async Task GetMemberAsync(string username, bool isCurrentUser) 14 | { 15 | var query = context.Users 16 | .Where(x => x.UserName == username) 17 | .ProjectTo(mapper.ConfigurationProvider) 18 | .AsQueryable(); 19 | 20 | if (isCurrentUser) query = query.IgnoreQueryFilters(); 21 | 22 | return await query.FirstOrDefaultAsync(); 23 | } 24 | 25 | public async Task> GetMembersAsync(UserParams userParams) 26 | { 27 | var query = context.Users.AsQueryable(); 28 | 29 | query = query.Where(x => x.UserName != userParams.CurrentUsername); 30 | 31 | if (userParams.Gender != null) 32 | { 33 | query = query.Where(x => x.Gender == userParams.Gender); 34 | } 35 | 36 | var minDob = DateOnly.FromDateTime(DateTime.Today.AddYears(-userParams.MaxAge - 1)); 37 | var maxDob = DateOnly.FromDateTime(DateTime.Today.AddYears(-userParams.MinAge)); 38 | 39 | query = query.Where(x => x.DateOfBirth >= minDob && x.DateOfBirth <= maxDob); 40 | 41 | query = userParams.OrderBy switch 42 | { 43 | "created" => query.OrderByDescending(x => x.Created), 44 | _ => query.OrderByDescending(x => x.LastActive) 45 | }; 46 | 47 | return await PagedList.CreateAsync(query.ProjectTo(mapper.ConfigurationProvider), 48 | userParams.PageNumber, userParams.PageSize); 49 | 50 | } 51 | 52 | public async Task GetUserByIdAsync(int id) 53 | { 54 | return await context.Users.FindAsync(id); 55 | } 56 | 57 | public async Task GetUserByPhotoId(int photoId) 58 | { 59 | return await context.Users 60 | .Include(p => p.Photos) 61 | .IgnoreQueryFilters() 62 | .Where(p => p.Photos.Any(p => p.Id == photoId)) 63 | .FirstOrDefaultAsync(); 64 | } 65 | 66 | public async Task GetUserByUsernameAsync(string username) 67 | { 68 | return await context.Users 69 | .Include(x => x.Photos) 70 | .SingleOrDefaultAsync(x => x.UserName == username); 71 | } 72 | 73 | public async Task> GetUsersAsync() 74 | { 75 | return await context.Users 76 | .Include(x => x.Photos) 77 | .ToListAsync(); 78 | } 79 | 80 | public void Update(AppUser user) 81 | { 82 | context.Entry(user).State = EntityState.Modified; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /API/Entities/AppRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace API; 4 | 5 | public class AppRole : IdentityRole 6 | { 7 | public ICollection UserRoles { get; set; } = []; 8 | } 9 | -------------------------------------------------------------------------------- /API/Entities/AppUser.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using Microsoft.AspNetCore.Identity; 3 | 4 | namespace API.Entities; 5 | 6 | public class AppUser : IdentityUser 7 | { 8 | public DateOnly DateOfBirth { get; set; } 9 | public required string KnownAs { get; set; } 10 | public DateTime Created { get; set; } = DateTime.UtcNow; 11 | public DateTime LastActive { get; set; } = DateTime.UtcNow; 12 | public required string Gender { get; set; } 13 | public string? Introduction { get; set; } 14 | public string? Interests { get; set; } 15 | public string? LookingFor { get; set; } 16 | public required string City { get; set; } 17 | public required string Country { get; set; } 18 | public List Photos { get; set; } = []; 19 | public List LikedByUsers { get; set; } = []; 20 | public List LikedUsers { get; set; } = []; 21 | public List MessagesSent { get; set; } = []; 22 | public List MessagesReceived { get; set; } = []; 23 | public ICollection UserRoles { get; set; } = []; 24 | } 25 | -------------------------------------------------------------------------------- /API/Entities/AppUserRole.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | using Microsoft.AspNetCore.Identity; 3 | 4 | namespace API; 5 | 6 | public class AppUserRole : IdentityUserRole 7 | { 8 | public AppUser User { get; set; } = null!; 9 | public AppRole Role { get; set; } = null!; 10 | } 11 | -------------------------------------------------------------------------------- /API/Entities/Connection.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities; 2 | 3 | public class Connection 4 | { 5 | public required string ConnectionId { get; set; } 6 | public required string Username { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /API/Entities/Group.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace API.Entities; 4 | 5 | public class Group 6 | { 7 | [Key] 8 | public required string Name { get; set; } 9 | public ICollection Connections { get; set; } = []; 10 | } 11 | -------------------------------------------------------------------------------- /API/Entities/Message.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | 3 | namespace API; 4 | 5 | public class Message 6 | { 7 | public int Id { get; set; } 8 | public required string SenderUsername { get; set; } 9 | public required string RecipientUsername { get; set; } 10 | public required string Content { get; set; } 11 | public DateTime? DateRead { get; set; } 12 | public DateTime MessageSent { get; set; } = DateTime.UtcNow; 13 | public bool SenderDeleted { get; set; } 14 | public bool RecipientDeleted { get; set; } 15 | 16 | // navigation properties 17 | public int SenderId { get; set; } 18 | public AppUser Sender { get; set; } = null!; 19 | public int RecipientId { get; set; } 20 | public AppUser Recipient { get; set; } = null!; 21 | } 22 | -------------------------------------------------------------------------------- /API/Entities/Photo.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace API.Entities; 4 | 5 | [Table("Photos")] 6 | public class Photo 7 | { 8 | public int Id { get; set; } 9 | public required string Url { get; set; } 10 | public bool IsMain { get; set; } 11 | public string? PublicId { get; set; } 12 | public bool IsApproved { get; set; } = false; 13 | 14 | // Navigation properties 15 | public int AppUserId { get; set; } 16 | public AppUser AppUser { get; set; } = null!; 17 | } -------------------------------------------------------------------------------- /API/Entities/UserLike.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | 3 | namespace API; 4 | 5 | public class UserLike 6 | { 7 | public AppUser SourceUser { get; set; } = null!; 8 | public int SourceUserId { get; set; } 9 | public AppUser TargetUser { get; set; } = null!; 10 | public int TargetUserId { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /API/Errors/ApiException.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class ApiException(int statusCode, string message, string? details) 4 | { 5 | public int StatusCode { get; set; } = statusCode; 6 | public string Message { get; set; } = message; 7 | public string? Details { get; set; } = details; 8 | } 9 | -------------------------------------------------------------------------------- /API/Extensions/ApplicationServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.Interfaces; 3 | using API.Services; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace API.Extensions; 7 | 8 | public static class ApplicationServiceExtensions 9 | { 10 | public static IServiceCollection AddApplicationServices(this IServiceCollection services, 11 | IConfiguration config) 12 | { 13 | services.AddControllers(); 14 | services.AddDbContext(opt => 15 | { 16 | opt.UseSqlServer(config.GetConnectionString("DefaultConnection")); 17 | }); 18 | services.AddCors(); 19 | services.AddScoped(); 20 | services.AddScoped(); 21 | services.AddScoped(); 22 | services.AddScoped(); 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | services.AddScoped(); 26 | services.AddScoped(); 27 | services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 28 | services.Configure(config.GetSection("CloudinarySettings")); 29 | services.AddSignalR(); 30 | services.AddSingleton(); 31 | 32 | return services; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /API/Extensions/ClaimsPrincipleExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | 3 | namespace API.Extensions; 4 | 5 | public static class ClaimsPrincipleExtensions 6 | { 7 | public static string GetUsername(this ClaimsPrincipal user) 8 | { 9 | var username = user.FindFirstValue(ClaimTypes.Name) 10 | ?? throw new Exception("Cannot get username from token"); 11 | 12 | return username; 13 | } 14 | 15 | public static int GetUserId(this ClaimsPrincipal user) 16 | { 17 | var userId = int.Parse(user.FindFirstValue(ClaimTypes.NameIdentifier) 18 | ?? throw new Exception("Cannot get username from token")); 19 | 20 | return userId; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /API/Extensions/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace API.Extensions; 2 | 3 | public static class DateTimeExtensions 4 | { 5 | public static int CalculateAge(this DateOnly dob) 6 | { 7 | var today = DateOnly.FromDateTime(DateTime.Now); 8 | 9 | var age = today.Year - dob.Year; 10 | 11 | if (dob > today.AddYears(-age)) age--; 12 | 13 | return age; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using API.Helpers; 3 | 4 | namespace API.Extensions; 5 | 6 | public static class HttpExtensions 7 | { 8 | public static void AddPaginationHeader(this HttpResponse response, PagedList data) 9 | { 10 | var paginationHeader = new PaginationHeader(data.CurrentPage, data.PageSize, 11 | data.TotalCount, data.TotalPages); 12 | 13 | var jsonOptions = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 14 | response.Headers.Append("Pagination", JsonSerializer.Serialize(paginationHeader, jsonOptions)); 15 | response.Headers.Append("Access-Control-Expose-Headers", "Pagination"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /API/Extensions/IdentityServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using API.Data; 3 | using API.Entities; 4 | using Microsoft.AspNetCore.Authentication.JwtBearer; 5 | using Microsoft.AspNetCore.Identity; 6 | using Microsoft.IdentityModel.Tokens; 7 | 8 | namespace API.Extensions; 9 | 10 | public static class IdentityServiceExtensions 11 | { 12 | public static IServiceCollection AddIdentityServices(this IServiceCollection services, 13 | IConfiguration config) 14 | { 15 | services.AddIdentityCore(opt => 16 | { 17 | opt.Password.RequireNonAlphanumeric = false; 18 | }) 19 | .AddRoles() 20 | .AddRoleManager>() 21 | .AddEntityFrameworkStores(); 22 | 23 | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 24 | .AddJwtBearer(options => 25 | { 26 | var tokenKey = config["TokenKey"] ?? throw new Exception("TokenKey not found"); 27 | options.TokenValidationParameters = new TokenValidationParameters 28 | { 29 | ValidateIssuerSigningKey = true, 30 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenKey)), 31 | ValidateIssuer = false, 32 | ValidateAudience = false 33 | }; 34 | 35 | options.Events = new JwtBearerEvents 36 | { 37 | OnMessageReceived = context => 38 | { 39 | var accessToken = context.Request.Query["access_token"]; 40 | 41 | var path = context.HttpContext.Request.Path; 42 | if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) 43 | { 44 | context.Token = accessToken; 45 | } 46 | 47 | return Task.CompletedTask; 48 | } 49 | }; 50 | }); 51 | 52 | services.AddAuthorizationBuilder() 53 | .AddPolicy("RequireAdminRole", policy => policy.RequireRole("Admin")) 54 | .AddPolicy("ModeratePhotoRole", policy => policy.RequireRole("Admin", "Moderator")); 55 | 56 | return services; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /API/Helpers/AutoMapperProfiles.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Entities; 3 | using API.Extensions; 4 | using AutoMapper; 5 | 6 | namespace API.Helpers; 7 | 8 | public class AutoMapperProfiles : Profile 9 | { 10 | public AutoMapperProfiles() 11 | { 12 | CreateMap() 13 | .ForMember(d => d.Age, o => o.MapFrom(s => s.DateOfBirth.CalculateAge())) 14 | .ForMember(d => d.PhotoUrl, o => 15 | o.MapFrom(s => s.Photos.FirstOrDefault(x => x.IsMain)!.Url)); 16 | CreateMap(); 17 | CreateMap(); 18 | CreateMap(); 19 | CreateMap().ConvertUsing(s => DateOnly.Parse(s)); 20 | CreateMap() 21 | .ForMember(d => d.SenderPhotoUrl, 22 | o => o.MapFrom(s => s.Sender.Photos.FirstOrDefault(x => x.IsMain)!.Url)) 23 | .ForMember(d => d.RecipientPhotoUrl, 24 | o => o.MapFrom(s => s.Recipient.Photos.FirstOrDefault(x => x.IsMain)!.Url)); 25 | CreateMap().ConvertUsing(d => DateTime.SpecifyKind(d, DateTimeKind.Utc)); 26 | CreateMap().ConvertUsing(d => d.HasValue 27 | ? DateTime.SpecifyKind(d.Value, DateTimeKind.Utc) : null); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /API/Helpers/CloudinarySettings.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class CloudinarySettings 4 | { 5 | public required string CloudName { get; set; } 6 | public required string ApiKey { get; set; } 7 | public required string ApiSecret { get; set; } 8 | } 9 | -------------------------------------------------------------------------------- /API/Helpers/LikesParams.cs: -------------------------------------------------------------------------------- 1 | namespace API.Helpers; 2 | 3 | public class LikesParams : PaginationParams 4 | { 5 | public int UserId { get; set; } 6 | public required string Predicate { get; set; } = "liked"; 7 | } 8 | -------------------------------------------------------------------------------- /API/Helpers/LogUserActivity.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using API.Interfaces; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | 5 | namespace API; 6 | 7 | public class LogUserActivity : IAsyncActionFilter 8 | { 9 | public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 10 | { 11 | var resultContext = await next(); 12 | 13 | if (context.HttpContext.User.Identity?.IsAuthenticated != true) return; 14 | 15 | var userId = resultContext.HttpContext.User.GetUserId(); 16 | 17 | var unitOfWork = resultContext.HttpContext.RequestServices.GetRequiredService(); 18 | var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); 19 | if (user == null) return; 20 | user.LastActive = DateTime.UtcNow; 21 | await unitOfWork.Complete(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /API/Helpers/MessageParams.cs: -------------------------------------------------------------------------------- 1 | using API.Helpers; 2 | 3 | namespace API; 4 | 5 | public class MessageParams : PaginationParams 6 | { 7 | public string? Username { get; set; } 8 | public string Container { get; set; } = "Unread"; 9 | } 10 | -------------------------------------------------------------------------------- /API/Helpers/PagedList.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace API.Helpers; 4 | 5 | public class PagedList : List 6 | { 7 | public PagedList(IEnumerable items, int count, int pageNumber, int pageSize) 8 | { 9 | CurrentPage = pageNumber; 10 | TotalPages = (int) Math.Ceiling(count / (double)pageSize); 11 | PageSize = pageSize; 12 | TotalCount = count; 13 | AddRange(items); 14 | } 15 | 16 | public int CurrentPage { get; set; } 17 | public int TotalPages { get; set; } 18 | public int PageSize { get; set; } 19 | public int TotalCount { get; set; } 20 | 21 | public static async Task> CreateAsync(IQueryable source, int pageNumber, 22 | int pageSize) 23 | { 24 | var count = await source.CountAsync(); 25 | var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); 26 | return new PagedList(items, count, pageNumber, pageSize); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /API/Helpers/PaginationHeader.cs: -------------------------------------------------------------------------------- 1 | namespace API.Helpers; 2 | 3 | public class PaginationHeader(int currentPage, int itemsPerPage, int totalItems, int totalPages) 4 | { 5 | public int CurrentPage { get; set; } = currentPage; 6 | public int ItemsPerPage { get; set; } = itemsPerPage; 7 | public int TotalItems { get; set; } = totalItems; 8 | public int TotalPages { get; set; } = totalPages; 9 | } 10 | -------------------------------------------------------------------------------- /API/Helpers/PaginationParams.cs: -------------------------------------------------------------------------------- 1 | namespace API.Helpers; 2 | 3 | public class PaginationParams 4 | { 5 | private const int MaxPageSize = 50; 6 | public int PageNumber { get; set; } = 1; 7 | private int _pageSize = 10; 8 | 9 | public int PageSize 10 | { 11 | get => _pageSize; 12 | set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /API/Helpers/UserParams.cs: -------------------------------------------------------------------------------- 1 | namespace API.Helpers; 2 | 3 | public class UserParams : PaginationParams 4 | { 5 | public string? Gender { get; set; } 6 | public string? CurrentUsername { get; set; } 7 | public int MinAge { get; set; } = 18; 8 | public int MaxAge { get; set; } = 100; 9 | public string OrderBy { get; set; } = "lastActive"; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /API/Interfaces/ILikesRepository.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Helpers; 3 | 4 | namespace API; 5 | 6 | public interface ILikesRepository 7 | { 8 | Task GetUserLike(int sourceUserId, int targetUserId); 9 | Task> GetUserLikes(LikesParams likesParams); 10 | Task> GetCurrentUserLikeIds(int currentUserId); 11 | void DeleteLike(UserLike like); 12 | void AddLike(UserLike like); 13 | } 14 | -------------------------------------------------------------------------------- /API/Interfaces/IMessageRepository.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Entities; 3 | using API.Helpers; 4 | 5 | namespace API.Interfaces; 6 | 7 | public interface IMessageRepository 8 | { 9 | void AddMessage(Message message); 10 | void DeleteMessage(Message message); 11 | Task GetMessage(int id); 12 | Task> GetMessagesForUser(MessageParams messageParams); 13 | Task> GetMessageThread(string currentUsername, string recipientUsername); 14 | void AddGroup(Group group); 15 | void RemoveConnection(Connection connection); 16 | Task GetConnection(string connectionId); 17 | Task GetMessageGroup(string groupName); 18 | Task GetGroupForConnection(string connectionId); 19 | } 20 | -------------------------------------------------------------------------------- /API/Interfaces/IPhotoRepository.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | 3 | namespace API.Interfaces; 4 | 5 | public interface IPhotoRepository 6 | { 7 | Task> GetUnapprovedPhotos(); 8 | Task GetPhotoById(int id); 9 | void RemovePhoto(Photo photo); 10 | } 11 | -------------------------------------------------------------------------------- /API/Interfaces/IPhotoService.cs: -------------------------------------------------------------------------------- 1 | using CloudinaryDotNet.Actions; 2 | 3 | namespace API.Interfaces; 4 | 5 | public interface IPhotoService 6 | { 7 | Task AddPhotoAsync(IFormFile file); 8 | Task DeletePhotoAsync(string publicId); 9 | } 10 | -------------------------------------------------------------------------------- /API/Interfaces/ITokenService.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | 3 | namespace API; 4 | 5 | public interface ITokenService 6 | { 7 | Task CreateToken(AppUser user); 8 | } 9 | -------------------------------------------------------------------------------- /API/Interfaces/IUnitOfWork.cs: -------------------------------------------------------------------------------- 1 | namespace API.Interfaces; 2 | 3 | public interface IUnitOfWork 4 | { 5 | IUserRepository UserRepository {get;} 6 | IMessageRepository MessageRepository {get;} 7 | ILikesRepository LikesRepository {get;} 8 | IPhotoRepository PhotoRepository {get;} 9 | Task Complete(); 10 | bool HasChanges(); 11 | } 12 | -------------------------------------------------------------------------------- /API/Interfaces/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Entities; 3 | using API.Helpers; 4 | 5 | namespace API; 6 | 7 | public interface IUserRepository 8 | { 9 | void Update(AppUser user); 10 | Task> GetUsersAsync(); 11 | Task GetUserByIdAsync(int id); 12 | Task GetUserByUsernameAsync(string username); 13 | Task> GetMembersAsync(UserParams userParams); 14 | Task GetMemberAsync(string username, bool isCurrentUser); 15 | Task GetUserByPhotoId(int photoId); 16 | } 17 | -------------------------------------------------------------------------------- /API/Middleware/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | 4 | namespace API; 5 | 6 | public class ExceptionMiddleware(RequestDelegate next, ILogger logger, 7 | IHostEnvironment env) 8 | { 9 | public async Task InvokeAsync(HttpContext context) 10 | { 11 | try 12 | { 13 | await next(context); 14 | } 15 | catch (Exception ex) 16 | { 17 | logger.LogError(ex, ex.Message); 18 | context.Response.ContentType = "application/json"; 19 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 20 | 21 | var response = env.IsDevelopment() 22 | ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace) 23 | : new ApiException(context.Response.StatusCode, ex.Message, "Internal server error"); 24 | 25 | var options = new JsonSerializerOptions 26 | { 27 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 28 | }; 29 | 30 | var json = JsonSerializer.Serialize(response, options); 31 | 32 | await context.Response.WriteAsync(json); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using API; 2 | using API.Data; 3 | using API.Entities; 4 | using API.Extensions; 5 | using API.SignalR; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | // Add services to the container. 12 | builder.Services.AddApplicationServices(builder.Configuration); 13 | builder.Services.AddIdentityServices(builder.Configuration); 14 | 15 | var app = builder.Build(); 16 | 17 | // Configure the HTTP request pipeline. 18 | app.UseMiddleware(); 19 | app.UseCors(x => x.AllowAnyHeader().AllowAnyMethod().AllowCredentials() 20 | .WithOrigins("http://localhost:4200", "https://localhost:4200")); 21 | 22 | app.UseAuthentication(); 23 | app.UseAuthorization(); 24 | 25 | app.UseDefaultFiles(); 26 | app.UseStaticFiles(); 27 | 28 | app.MapControllers(); 29 | app.MapHub("hubs/presence"); 30 | app.MapHub("hubs/message"); 31 | app.MapFallbackToController("Index", "Fallback"); 32 | 33 | using var scope = app.Services.CreateScope(); 34 | var services = scope.ServiceProvider; 35 | try 36 | { 37 | var context = services.GetRequiredService(); 38 | var userManager = services.GetRequiredService>(); 39 | var roleManager = services.GetRequiredService>(); 40 | await context.Database.MigrateAsync(); 41 | await context.Database.ExecuteSqlRawAsync("DELETE FROM [Connections]"); 42 | await Seed.SeedUsers(userManager, roleManager); 43 | } 44 | catch (Exception ex) 45 | { 46 | var logger = services.GetRequiredService>(); 47 | logger.LogError(ex, "An error occurred during migration"); 48 | } 49 | 50 | app.Run(); 51 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5000;https://localhost:5001", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /API/Services/PhotoService.cs: -------------------------------------------------------------------------------- 1 | using API.Interfaces; 2 | using CloudinaryDotNet; 3 | using CloudinaryDotNet.Actions; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace API.Services; 7 | 8 | public class PhotoService : IPhotoService 9 | { 10 | private readonly Cloudinary _cloudinary; 11 | public PhotoService(IOptions config) 12 | { 13 | var acc = new Account(config.Value.CloudName, config.Value.ApiKey, config.Value.ApiSecret); 14 | 15 | _cloudinary = new Cloudinary(acc); 16 | } 17 | 18 | public async Task AddPhotoAsync(IFormFile file) 19 | { 20 | var uploadResult = new ImageUploadResult(); 21 | 22 | if (file.Length > 0) 23 | { 24 | using var stream = file.OpenReadStream(); 25 | var uploadParams = new ImageUploadParams 26 | { 27 | File = new FileDescription(file.FileName, stream), 28 | Transformation = new Transformation() 29 | .Height(500).Width(500).Crop("fill").Gravity("face"), 30 | Folder = "da-net8" 31 | }; 32 | 33 | uploadResult = await _cloudinary.UploadAsync(uploadParams); 34 | } 35 | 36 | return uploadResult; 37 | } 38 | 39 | public async Task DeletePhotoAsync(string publicId) 40 | { 41 | var deleteParams = new DeletionParams(publicId); 42 | 43 | return await _cloudinary.DestroyAsync(deleteParams); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /API/Services/TokenService.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Claims; 3 | using System.Text; 4 | using API.Entities; 5 | using Microsoft.AspNetCore.Identity; 6 | using Microsoft.IdentityModel.Tokens; 7 | 8 | namespace API; 9 | 10 | public class TokenService(IConfiguration config, UserManager userManager) : ITokenService 11 | { 12 | public async Task CreateToken(AppUser user) 13 | { 14 | var tokenKey = config["TokenKey"] ?? throw new Exception("Cannot access tokenKey from appsettings"); 15 | if (tokenKey.Length < 64) throw new Exception("Your tokenKey needs to be longer"); 16 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenKey)); 17 | 18 | if (user.UserName == null) throw new Exception("No username for user"); 19 | 20 | var claims = new List 21 | { 22 | new(ClaimTypes.NameIdentifier, user.Id.ToString()), 23 | new(ClaimTypes.Name, user.UserName) 24 | }; 25 | 26 | var roles = await userManager.GetRolesAsync(user); 27 | 28 | claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); 29 | 30 | var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); 31 | 32 | var tokenDescriptor = new SecurityTokenDescriptor 33 | { 34 | Subject = new ClaimsIdentity(claims), 35 | Expires = DateTime.UtcNow.AddDays(7), 36 | SigningCredentials = creds 37 | }; 38 | 39 | var tokenHandler = new JwtSecurityTokenHandler(); 40 | var token = tokenHandler.CreateToken(tokenDescriptor); 41 | 42 | return tokenHandler.WriteToken(token); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /API/SignalR/MessageHub.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Entities; 3 | using API.Extensions; 4 | using API.Interfaces; 5 | using AutoMapper; 6 | using Microsoft.AspNetCore.SignalR; 7 | 8 | namespace API.SignalR; 9 | 10 | public class MessageHub(IUnitOfWork unitOfWork, 11 | IMapper mapper, IHubContext presenceHub) : Hub 12 | { 13 | public override async Task OnConnectedAsync() 14 | { 15 | var httpContext = Context.GetHttpContext(); 16 | var otherUser = httpContext?.Request.Query["user"]; 17 | 18 | if (Context.User == null || string.IsNullOrEmpty(otherUser)) 19 | throw new Exception("Cannot join group"); 20 | var groupName = GetGroupName(Context.User.GetUsername(), otherUser); 21 | await Groups.AddToGroupAsync(Context.ConnectionId, groupName); 22 | var group = await AddToGroup(groupName); 23 | 24 | await Clients.Group(groupName).SendAsync("UpdatedGroup", group); 25 | 26 | var messages = await unitOfWork.MessageRepository.GetMessageThread(Context.User.GetUsername(), otherUser!); 27 | 28 | if (unitOfWork.HasChanges()) await unitOfWork.Complete(); 29 | 30 | await Clients.Caller.SendAsync("ReceiveMessageThread", messages); 31 | 32 | } 33 | 34 | public override async Task OnDisconnectedAsync(Exception? exception) 35 | { 36 | var group = await RemoveFromMessageGroup(); 37 | await Clients.Group(group.Name).SendAsync("UpdatedGroup", group); 38 | await base.OnDisconnectedAsync(exception); 39 | } 40 | 41 | public async Task SendMessage(CreateMessageDto createMessageDto) 42 | { 43 | var username = Context.User?.GetUsername() ?? throw new Exception("could not get user"); 44 | 45 | if (username == createMessageDto.RecipientUsername.ToLower()) 46 | throw new HubException("You cannot message yourself"); 47 | 48 | var sender = await unitOfWork.UserRepository.GetUserByUsernameAsync(username); 49 | var recipient = await unitOfWork.UserRepository.GetUserByUsernameAsync(createMessageDto.RecipientUsername); 50 | 51 | if (recipient == null || sender == null || sender.UserName == null || recipient.UserName == null) 52 | throw new HubException("Cannot send message at this time"); 53 | 54 | var message = new Message 55 | { 56 | Sender = sender, 57 | Recipient = recipient, 58 | SenderUsername = sender.UserName, 59 | RecipientUsername = recipient.UserName, 60 | Content = createMessageDto.Content 61 | }; 62 | 63 | var groupName = GetGroupName(sender.UserName, recipient.UserName); 64 | var group = await unitOfWork.MessageRepository.GetMessageGroup(groupName); 65 | 66 | if (group != null && group.Connections.Any(x => x.Username == recipient.UserName)) 67 | { 68 | message.DateRead = DateTime.UtcNow; 69 | } 70 | else 71 | { 72 | var connections = await PresenceTracker.GetConnectionsForUser(recipient.UserName); 73 | if (connections != null && connections?.Count != null) 74 | { 75 | await presenceHub.Clients.Clients(connections).SendAsync("NewMessageReceived", 76 | new {username = sender.UserName, knownAs = sender.KnownAs}); 77 | } 78 | } 79 | 80 | unitOfWork.MessageRepository.AddMessage(message); 81 | 82 | if (await unitOfWork.Complete()) 83 | { 84 | await Clients.Group(groupName).SendAsync("NewMessage", mapper.Map(message)); 85 | } 86 | } 87 | 88 | private async Task AddToGroup(string groupName) 89 | { 90 | var username = Context.User?.GetUsername() ?? throw new Exception("Cannot get username"); 91 | var group = await unitOfWork.MessageRepository.GetMessageGroup(groupName); 92 | var connection = new Connection{ConnectionId = Context.ConnectionId, Username = username}; 93 | 94 | if (group == null) 95 | { 96 | group = new Group{Name = groupName}; 97 | unitOfWork.MessageRepository.AddGroup(group); 98 | } 99 | 100 | group.Connections.Add(connection); 101 | 102 | if (await unitOfWork.Complete()) return group; 103 | 104 | throw new HubException("Failed to join group"); 105 | } 106 | 107 | private async Task RemoveFromMessageGroup() 108 | { 109 | var group = await unitOfWork.MessageRepository.GetGroupForConnection(Context.ConnectionId); 110 | var connection = group?.Connections.FirstOrDefault(x => x.ConnectionId == Context.ConnectionId); 111 | if (connection != null && group != null) 112 | { 113 | unitOfWork.MessageRepository.RemoveConnection(connection); 114 | if (await unitOfWork.Complete()) return group; 115 | } 116 | 117 | throw new Exception("Failed to remove from group"); 118 | } 119 | 120 | private string GetGroupName(string caller, string? other) 121 | { 122 | var stringCompare = string.CompareOrdinal(caller, other) < 0; 123 | return stringCompare ? $"{caller}-{other}" : $"{other}-{caller}"; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /API/SignalR/PresenceHub.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.SignalR; 4 | 5 | namespace API.SignalR; 6 | 7 | [Authorize] 8 | public class PresenceHub(PresenceTracker tracker) : Hub 9 | { 10 | public override async Task OnConnectedAsync() 11 | { 12 | if (Context.User == null) throw new HubException("Cannot get current user claim"); 13 | 14 | var isOnline = await tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); 15 | if (isOnline) await Clients.Others.SendAsync("UserIsOnline", Context.User?.GetUsername()); 16 | 17 | var currentUsers = await tracker.GetOnlineUsers(); 18 | await Clients.Caller.SendAsync("GetOnlineUsers", currentUsers); 19 | } 20 | 21 | public override async Task OnDisconnectedAsync(Exception? exception) 22 | { 23 | if (Context.User == null) throw new HubException("Cannot get current user claim"); 24 | 25 | var isOffline = await tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); 26 | if (isOffline) await Clients.Others.SendAsync("UserIsOffline", Context.User?.GetUsername()); 27 | 28 | await base.OnDisconnectedAsync(exception); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /API/SignalR/PresenceTracker.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class PresenceTracker 4 | { 5 | private static readonly Dictionary> OnlineUsers = []; 6 | 7 | public Task UserConnected(string username, string connectionId) 8 | { 9 | var isOnline = false; 10 | lock (OnlineUsers) 11 | { 12 | if (OnlineUsers.ContainsKey(username)) 13 | { 14 | OnlineUsers[username].Add(connectionId); 15 | } 16 | else 17 | { 18 | OnlineUsers.Add(username, [connectionId]); 19 | isOnline = true; 20 | } 21 | } 22 | 23 | return Task.FromResult(isOnline); 24 | } 25 | 26 | public Task UserDisconnected(string username, string connectionId) 27 | { 28 | var isOffline = false; 29 | lock (OnlineUsers) 30 | { 31 | if (!OnlineUsers.ContainsKey(username)) return Task.FromResult(isOffline); 32 | 33 | OnlineUsers[username].Remove(connectionId); 34 | 35 | if (OnlineUsers[username].Count == 0) 36 | { 37 | OnlineUsers.Remove(username); 38 | isOffline = true; 39 | } 40 | } 41 | 42 | return Task.FromResult(isOffline); 43 | } 44 | 45 | public Task GetOnlineUsers() 46 | { 47 | string[] onlineUsers; 48 | lock(OnlineUsers) 49 | { 50 | onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); 51 | } 52 | 53 | return Task.FromResult(onlineUsers); 54 | } 55 | 56 | public static Task> GetConnectionsForUser(string username) 57 | { 58 | List connectionIds; 59 | 60 | if (OnlineUsers.TryGetValue(username, out var connections)) 61 | { 62 | lock(connections) 63 | { 64 | connectionIds = connections.ToList(); 65 | } 66 | } 67 | else 68 | { 69 | connectionIds = []; 70 | } 71 | 72 | return Task.FromResult(connectionIds); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /API/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class WeatherForecast 4 | { 5 | public DateOnly Date { get; set; } 6 | 7 | public int TemperatureC { get; set; } 8 | 9 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 10 | 11 | public string? Summary { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "ConnectionStrings": { 9 | "DefaultConnection": "Server=localhost,1433;Database=DatingDB;User Id=SA;Password=Password@1;TrustServerCertificate=True" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /API/wwwroot/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/assets/user.png -------------------------------------------------------------------------------- /API/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/favicon.ico -------------------------------------------------------------------------------- /API/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /API/wwwroot/media/fontawesome-webfont-5GKVPAEF.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/media/fontawesome-webfont-5GKVPAEF.woff2 -------------------------------------------------------------------------------- /API/wwwroot/media/fontawesome-webfont-FMJ3VJ65.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/media/fontawesome-webfont-FMJ3VJ65.eot -------------------------------------------------------------------------------- /API/wwwroot/media/fontawesome-webfont-RJ6LE7IU.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/media/fontawesome-webfont-RJ6LE7IU.ttf -------------------------------------------------------------------------------- /API/wwwroot/media/fontawesome-webfont-Z4ARLA73.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/API/wwwroot/media/fontawesome-webfont-Z4ARLA73.woff -------------------------------------------------------------------------------- /DatingApp.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{2EC889BF-6CEA-48A2-B616-DE3393362DCB}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(SolutionProperties) = preSolution 14 | HideSolutionNode = FALSE 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {2EC889BF-6CEA-48A2-B616-DE3393362DCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {2EC889BF-6CEA-48A2-B616-DE3393362DCB}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {2EC889BF-6CEA-48A2-B616-DE3393362DCB}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {2EC889BF-6CEA-48A2-B616-DE3393362DCB}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /client/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /client/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.7. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /client/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "client": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:application", 15 | "options": { 16 | "outputPath": { 17 | "base": "../API/wwwroot", 18 | "browser": "" 19 | }, 20 | "index": "src/index.html", 21 | "browser": "src/main.ts", 22 | "polyfills": [ 23 | "zone.js" 24 | ], 25 | "tsConfig": "tsconfig.app.json", 26 | "assets": [ 27 | "src/favicon.ico", 28 | "src/assets" 29 | ], 30 | "styles": [ 31 | "node_modules/ngx-bootstrap/datepicker/bs-datepicker.css", 32 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 33 | "node_modules/font-awesome/css/font-awesome.min.css", 34 | "node_modules/bootswatch/dist/united/bootstrap.min.css", 35 | "node_modules/ngx-spinner/animations/line-scale-party.css", 36 | "node_modules/ngx-toastr/toastr.css", 37 | "src/styles.css" 38 | ], 39 | "scripts": [] 40 | }, 41 | "configurations": { 42 | "production": { 43 | "budgets": [ 44 | { 45 | "type": "initial", 46 | "maximumWarning": "1.5mb", 47 | "maximumError": "2mb" 48 | }, 49 | { 50 | "type": "anyComponentStyle", 51 | "maximumWarning": "2kb", 52 | "maximumError": "4kb" 53 | } 54 | ], 55 | "optimization": { 56 | "styles": { 57 | "inlineCritical": false 58 | } 59 | }, 60 | "outputHashing": "all" 61 | }, 62 | "development": { 63 | "optimization": false, 64 | "extractLicenses": false, 65 | "sourceMap": true, 66 | "fileReplacements": [ 67 | { 68 | "replace": "src/environments/environment.ts", 69 | "with": "src/environments/environment.development.ts" 70 | } 71 | ] 72 | } 73 | }, 74 | "defaultConfiguration": "production" 75 | }, 76 | "serve": { 77 | "options": { 78 | "ssl": true, 79 | "sslCert": "./ssl/localhost.pem", 80 | "sslKey": "./ssl/localhost-key.pem" 81 | }, 82 | "builder": "@angular-devkit/build-angular:dev-server", 83 | "configurations": { 84 | "production": { 85 | "buildTarget": "client:build:production" 86 | }, 87 | "development": { 88 | "buildTarget": "client:build:development" 89 | } 90 | }, 91 | "defaultConfiguration": "development" 92 | }, 93 | "extract-i18n": { 94 | "builder": "@angular-devkit/build-angular:extract-i18n", 95 | "options": { 96 | "buildTarget": "client:build" 97 | } 98 | }, 99 | "test": { 100 | "builder": "@angular-devkit/build-angular:karma", 101 | "options": { 102 | "polyfills": [ 103 | "zone.js", 104 | "zone.js/testing" 105 | ], 106 | "tsConfig": "tsconfig.spec.json", 107 | "assets": [ 108 | "src/favicon.ico", 109 | "src/assets" 110 | ], 111 | "styles": [ 112 | "src/styles.css" 113 | ], 114 | "scripts": [] 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^17.3.0", 14 | "@angular/common": "^17.3.0", 15 | "@angular/compiler": "^17.3.0", 16 | "@angular/core": "^17.3.0", 17 | "@angular/forms": "^17.3.0", 18 | "@angular/platform-browser": "^17.3.0", 19 | "@angular/platform-browser-dynamic": "^17.3.0", 20 | "@angular/router": "^17.3.0", 21 | "@microsoft/signalr": "7.0.14", 22 | "bootstrap": "^5.3.3", 23 | "bootswatch": "^5.3.3", 24 | "font-awesome": "^4.7.0", 25 | "ng-gallery": "^11.0.0", 26 | "ng2-file-upload": "^5.0.0", 27 | "ngx-bootstrap": "^12.0.0", 28 | "ngx-spinner": "^17.0.0", 29 | "ngx-timeago": "^3.0.0", 30 | "ngx-toastr": "^18.0.0", 31 | "rxjs": "~7.8.0", 32 | "tslib": "^2.3.0", 33 | "zone.js": "~0.14.3" 34 | }, 35 | "devDependencies": { 36 | "@angular-devkit/build-angular": "^17.3.7", 37 | "@angular/cli": "^17.3.7", 38 | "@angular/compiler-cli": "^17.3.0", 39 | "@types/jasmine": "~5.1.0", 40 | "jasmine-core": "~5.1.0", 41 | "karma": "~6.4.0", 42 | "karma-chrome-launcher": "~3.2.0", 43 | "karma-coverage": "~2.2.0", 44 | "karma-jasmine": "~5.1.0", 45 | "karma-jasmine-html-reporter": "~2.1.0", 46 | "typescript": "~5.4.2" 47 | }, 48 | "overrides": { 49 | "ng2-file-upload": { 50 | "@angular/common": "$@angular/common", 51 | "@angular/core": "$@angular/core" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/app/_directives/has-role.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, OnInit, TemplateRef, ViewContainerRef, inject } from '@angular/core'; 2 | import { AccountService } from '../_services/account.service'; 3 | 4 | @Directive({ 5 | selector: '[appHasRole]', // *appHasRole 6 | standalone: true 7 | }) 8 | export class HasRoleDirective implements OnInit { 9 | @Input() appHasRole: string[] = []; 10 | private accountService = inject(AccountService); 11 | private viewContainerRef = inject(ViewContainerRef); 12 | private templateRef = inject(TemplateRef); 13 | 14 | ngOnInit(): void { 15 | if (this.accountService.roles()?.some((r: string) => this.appHasRole.includes(r))) { 16 | this.viewContainerRef.createEmbeddedView(this.templateRef) 17 | } else { 18 | this.viewContainerRef.clear(); 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/_forms/date-picker/date-picker.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/_forms/date-picker/date-picker.component.css -------------------------------------------------------------------------------- /client/src/app/_forms/date-picker/date-picker.component.html: -------------------------------------------------------------------------------- 1 |
2 | 11 | 12 |
Please enter a {{label()}}
14 |
15 | -------------------------------------------------------------------------------- /client/src/app/_forms/date-picker/date-picker.component.ts: -------------------------------------------------------------------------------- 1 | import { NgIf } from '@angular/common'; 2 | import { Component, Self, input } from '@angular/core'; 3 | import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms'; 4 | import { BsDatepickerConfig, BsDatepickerModule } from 'ngx-bootstrap/datepicker'; 5 | 6 | @Component({ 7 | selector: 'app-date-picker', 8 | standalone: true, 9 | imports: [BsDatepickerModule, NgIf, ReactiveFormsModule], 10 | templateUrl: './date-picker.component.html', 11 | styleUrl: './date-picker.component.css' 12 | }) 13 | export class DatePickerComponent implements ControlValueAccessor { 14 | label = input(''); 15 | maxDate = input(); 16 | bsConfig?: Partial; 17 | 18 | constructor(@Self() public ngControl: NgControl) { 19 | this.ngControl.valueAccessor = this; 20 | this.bsConfig = { 21 | containerClass: 'theme-red', 22 | dateInputFormat: 'DD MMMM YYYY' 23 | } 24 | } 25 | 26 | writeValue(obj: any): void { 27 | } 28 | 29 | registerOnChange(fn: any): void { 30 | } 31 | 32 | registerOnTouched(fn: any): void { 33 | } 34 | 35 | get control(): FormControl { 36 | return this.ngControl.control as FormControl 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/_forms/text-input/text-input.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/_forms/text-input/text-input.component.css -------------------------------------------------------------------------------- /client/src/app/_forms/text-input/text-input.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 |
Please enter a {{label()}}
12 |
14 | {{label()}} must be at least {{control.errors?.['minlength'].requiredLength}} characters 15 |
16 |
18 | {{label()}} must be at most {{control.errors?.['maxlength'].requiredLength}} characters 19 |
20 |
22 | Passwords do not match 23 |
24 |
25 | -------------------------------------------------------------------------------- /client/src/app/_forms/text-input/text-input.component.ts: -------------------------------------------------------------------------------- 1 | import { NgIf } from '@angular/common'; 2 | import { Component, Self, input } from '@angular/core'; 3 | import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | @Component({ 6 | selector: 'app-text-input', 7 | standalone: true, 8 | imports: [NgIf, ReactiveFormsModule], 9 | templateUrl: './text-input.component.html', 10 | styleUrl: './text-input.component.css' 11 | }) 12 | export class TextInputComponent implements ControlValueAccessor { 13 | label = input(''); 14 | type = input('text'); 15 | 16 | constructor(@Self() public ngControl: NgControl) { 17 | this.ngControl.valueAccessor = this 18 | } 19 | 20 | writeValue(obj: any): void { 21 | } 22 | 23 | registerOnChange(fn: any): void { 24 | } 25 | 26 | registerOnTouched(fn: any): void { 27 | } 28 | 29 | get control(): FormControl { 30 | return this.ngControl.control as FormControl 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/_guards/admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { CanActivateFn } from '@angular/router'; 3 | import { AccountService } from '../_services/account.service'; 4 | import { ToastrService } from 'ngx-toastr'; 5 | 6 | export const adminGuard: CanActivateFn = (route, state) => { 7 | const accountService = inject(AccountService); 8 | const toastr = inject(ToastrService); 9 | 10 | if (accountService.roles().includes('Admin') || accountService.roles().includes('Moderator')) { 11 | return true; 12 | } else { 13 | toastr.error('You cannot enter this area'); 14 | return false; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/app/_guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { CanActivateFn } from '@angular/router'; 3 | import { AccountService } from '../_services/account.service'; 4 | import { ToastrService } from 'ngx-toastr'; 5 | 6 | export const authGuard: CanActivateFn = (route, state) => { 7 | const accountService = inject(AccountService); 8 | const toastr = inject(ToastrService); 9 | 10 | if (accountService.currentUser()) { 11 | return true; 12 | } else { 13 | toastr.error('You shall not pass!'); 14 | return false; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/app/_guards/prevent-unsaved-changes.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanDeactivateFn } from '@angular/router'; 2 | import { MemberEditComponent } from '../members/member-edit/member-edit.component'; 3 | import { inject } from '@angular/core'; 4 | import { ConfirmService } from '../_services/confirm.service'; 5 | 6 | export const preventUnsavedChangesGuard: CanDeactivateFn = (component) => { 7 | const confirmService = inject(ConfirmService); 8 | 9 | if (component.editForm?.dirty) { 10 | return confirmService.confirm() ?? false; 11 | } 12 | return true; 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/app/_interceptors/error.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpInterceptorFn } from '@angular/common/http'; 2 | import { inject } from '@angular/core'; 3 | import { NavigationExtras, Router } from '@angular/router'; 4 | import { ToastrService } from 'ngx-toastr'; 5 | import { catchError } from 'rxjs'; 6 | 7 | export const errorInterceptor: HttpInterceptorFn = (req, next) => { 8 | const router = inject(Router); 9 | const toastr = inject(ToastrService); 10 | 11 | return next(req).pipe( 12 | catchError(error => { 13 | if (error) { 14 | switch (error.status) { 15 | case 400: 16 | if (error.error.errors) { 17 | const modalStateErrors = []; 18 | for (const key in error.error.errors) { 19 | if (error.error.errors[key]) { 20 | modalStateErrors.push(error.error.errors[key]) 21 | } 22 | } 23 | throw modalStateErrors.flat(); 24 | } else { 25 | toastr.error(error.error, error.status) 26 | } 27 | break; 28 | case 401: 29 | toastr.error('Unauthorised', error.status) 30 | break; 31 | case 404: 32 | router.navigateByUrl('/not-found'); 33 | break; 34 | case 500: 35 | const navigationExtras: NavigationExtras = {state: {error: error.error}}; 36 | router.navigateByUrl('/server-error', navigationExtras); 37 | break; 38 | default: 39 | toastr.error('Something unexpected went wrong'); 40 | break; 41 | } 42 | } 43 | throw error; 44 | }) 45 | ) 46 | }; 47 | -------------------------------------------------------------------------------- /client/src/app/_interceptors/jwt.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpInterceptorFn } from '@angular/common/http'; 2 | import { inject } from '@angular/core'; 3 | import { AccountService } from '../_services/account.service'; 4 | 5 | export const jwtInterceptor: HttpInterceptorFn = (req, next) => { 6 | const accountService = inject(AccountService); 7 | 8 | if (accountService.currentUser()) { 9 | req = req.clone({ 10 | setHeaders: { 11 | Authorization: `Bearer ${accountService.currentUser()?.token}` 12 | } 13 | }) 14 | } 15 | 16 | return next(req); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/app/_interceptors/loading.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpInterceptorFn } from '@angular/common/http'; 2 | import { inject } from '@angular/core'; 3 | import { BusyService } from '../_services/busy.service'; 4 | import { delay, finalize, identity } from 'rxjs'; 5 | import { environment } from '../../environments/environment'; 6 | 7 | export const loadingInterceptor: HttpInterceptorFn = (req, next) => { 8 | const busyService = inject(BusyService); 9 | 10 | busyService.busy(); 11 | 12 | return next(req).pipe( 13 | (environment.production ? identity : delay(1000)), 14 | finalize(() => { 15 | busyService.idle() 16 | }) 17 | ) 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/app/_models/group.ts: -------------------------------------------------------------------------------- 1 | export interface Group { 2 | name: string; 3 | connections: Connection[] 4 | } 5 | 6 | export interface Connection { 7 | connectionId: string; 8 | username: string; 9 | } -------------------------------------------------------------------------------- /client/src/app/_models/member.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from "./photo" 2 | 3 | export interface Member { 4 | id: number 5 | username: string 6 | age: number 7 | photoUrl: string 8 | knownAs: string 9 | created: Date 10 | lastActive: Date 11 | gender: string 12 | introduction: string 13 | interests: string 14 | lookingFor: string 15 | city: string 16 | country: string 17 | photos: Photo[] 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/_models/message.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: number 3 | senderId: number 4 | senderUsername: string 5 | senderPhotoUrl: string 6 | recipientId: number 7 | recipientUsername: string 8 | recipientPhotoUrl: string 9 | content: string 10 | dateRead?: Date 11 | messageSent: Date 12 | } -------------------------------------------------------------------------------- /client/src/app/_models/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface Pagination { 2 | currentPage: number; 3 | itemsPerPage: number; 4 | totalItems: number; 5 | totalPages: number; 6 | } 7 | 8 | export class PaginatedResult { 9 | items?: T; 10 | pagination?: Pagination 11 | } -------------------------------------------------------------------------------- /client/src/app/_models/photo.ts: -------------------------------------------------------------------------------- 1 | export interface Photo { 2 | id: number 3 | url: string 4 | isMain: boolean 5 | isApproved: boolean; 6 | username?: string; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/_models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: string; 3 | knownAs: string; 4 | gender: string; 5 | token: string; 6 | photoUrl?: string; 7 | roles: string[]; 8 | } -------------------------------------------------------------------------------- /client/src/app/_models/userParams.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | 3 | export class UserParams { 4 | gender: string; 5 | minAge = 18; 6 | maxAge = 99; 7 | pageNumber = 1; 8 | pageSize = 5; 9 | orderBy = 'lastActive'; 10 | 11 | constructor(user: User | null) { 12 | this.gender = user?.gender === 'female' ? 'male' : 'female' 13 | } 14 | } -------------------------------------------------------------------------------- /client/src/app/_resolvers/member-detailed.resolver.ts: -------------------------------------------------------------------------------- 1 | import { ResolveFn } from '@angular/router'; 2 | import { Member } from '../_models/member'; 3 | import { inject } from '@angular/core'; 4 | import { MembersService } from '../_services/members.service'; 5 | 6 | export const memberDetailedResolver: ResolveFn = (route, state) => { 7 | const memberService = inject(MembersService); 8 | 9 | const username = route.paramMap.get('username'); 10 | 11 | if (!username) return null; 12 | 13 | return memberService.getMember(username); 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/app/_services/account.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable, computed, inject, signal } from '@angular/core'; 3 | import { User } from '../_models/user'; 4 | import { map } from 'rxjs'; 5 | import { environment } from '../../environments/environment'; 6 | import { LikesService } from './likes.service'; 7 | import { PresenceService } from './presence.service'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class AccountService { 13 | private http = inject(HttpClient); 14 | private likeService = inject(LikesService); 15 | private presenceService = inject(PresenceService); 16 | baseUrl = environment.apiUrl; 17 | currentUser = signal(null); 18 | roles = computed(() => { 19 | const user = this.currentUser(); 20 | if (user && user.token) { 21 | const role = JSON.parse(atob(user.token.split('.')[1])).role; 22 | return Array.isArray(role) ? role : [role]; 23 | } 24 | return []; 25 | }) 26 | 27 | login(model: any) { 28 | return this.http.post(this.baseUrl + 'account/login', model).pipe( 29 | map(user => { 30 | if (user) { 31 | this.setCurrentUser(user); 32 | } 33 | }) 34 | ) 35 | } 36 | 37 | register(model: any) { 38 | return this.http.post(this.baseUrl + 'account/register', model).pipe( 39 | map(user => { 40 | if (user) { 41 | this.setCurrentUser(user); 42 | } 43 | return user; 44 | }) 45 | ) 46 | } 47 | 48 | setCurrentUser(user: User) { 49 | localStorage.setItem('user', JSON.stringify(user)); 50 | this.currentUser.set(user); 51 | this.likeService.getLikeIds(); 52 | this.presenceService.createHubConnection(user) 53 | } 54 | 55 | logout() { 56 | localStorage.removeItem('user'); 57 | this.currentUser.set(null); 58 | this.presenceService.stopHubConnection(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/app/_services/admin.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { User } from '../_models/user'; 5 | import { Photo } from '../_models/photo'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class AdminService { 11 | baseUrl = environment.apiUrl; 12 | private http = inject(HttpClient); 13 | 14 | getUserWithRoles() { 15 | return this.http.get(this.baseUrl + 'admin/users-with-roles'); 16 | } 17 | 18 | updateUserRoles(username: string, roles: string[]) { 19 | return this.http.post(this.baseUrl + 'admin/edit-roles/' 20 | + username + '?roles=' + roles, {}); 21 | } 22 | 23 | getPhotosForApproval() { 24 | return this.http.get(this.baseUrl + 'admin/photos-to-moderate'); 25 | } 26 | 27 | approvePhoto(photoId: number) { 28 | return this.http.post(this.baseUrl + 'admin/approve-photo/' + photoId, {}); 29 | } 30 | 31 | rejectPhoto(photoId: number) { 32 | return this.http.post(this.baseUrl + 'admin/reject-photo/' + photoId, {}); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/app/_services/busy.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { NgxSpinnerService } from 'ngx-spinner'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class BusyService { 8 | busyRequestCount = 0; 9 | private spinnerService = inject(NgxSpinnerService); 10 | 11 | busy() { 12 | this.busyRequestCount++; 13 | this.spinnerService.show(undefined, { 14 | type: 'line-scale-party', 15 | bdColor: 'rgba(255,255,255,0)', 16 | color: '#333333' 17 | }) 18 | } 19 | 20 | idle() { 21 | this.busyRequestCount--; 22 | if (this.busyRequestCount <= 0) { 23 | this.busyRequestCount = 0; 24 | this.spinnerService.hide(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/_services/confirm.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { BsModalRef, BsModalService, ModalOptions } from 'ngx-bootstrap/modal'; 3 | import { ConfirmDialogComponent } from '../modals/confirm-dialog/confirm-dialog.component'; 4 | import { map } from 'rxjs'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ConfirmService { 10 | bsModalRef?: BsModalRef; 11 | private modalService = inject(BsModalService); 12 | 13 | confirm( 14 | title = 'Confirmation', 15 | message = 'Are you sure you want to do this?', 16 | btnOkText = 'Ok', 17 | btnCancelText = 'Cancel' 18 | ) { 19 | const config: ModalOptions = { 20 | initialState: { 21 | title, 22 | message, 23 | btnOkText, 24 | btnCancelText 25 | } 26 | }; 27 | this.bsModalRef = this.modalService.show(ConfirmDialogComponent, config); 28 | return this.bsModalRef.onHidden?.pipe( 29 | map(() => { 30 | if (this.bsModalRef?.content) { 31 | return this.bsModalRef.content.result; 32 | } else return false; 33 | }) 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/app/_services/likes.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, signal } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Member } from '../_models/member'; 5 | import { PaginatedResult } from '../_models/pagination'; 6 | import { setPaginatedResponse, setPaginationHeaders } from './paginationHelper'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class LikesService { 12 | baseUrl = environment.apiUrl; 13 | private http = inject(HttpClient); 14 | likeIds = signal([]); 15 | paginatedResult = signal | null>(null); 16 | 17 | toggleLike(targetId: number) { 18 | return this.http.post(`${this.baseUrl}likes/${targetId}`, {}) 19 | } 20 | 21 | getLikes(predicate: string, pageNumber: number, pageSize: number) { 22 | let params = setPaginationHeaders(pageNumber, pageSize); 23 | 24 | params = params.append('predicate', predicate); 25 | 26 | return this.http.get(`${this.baseUrl}likes`, 27 | {observe: 'response', params}).subscribe({ 28 | next: response => setPaginatedResponse(response, this.paginatedResult) 29 | }) 30 | } 31 | 32 | getLikeIds() { 33 | return this.http.get(`${this.baseUrl}likes/list`).subscribe({ 34 | next: ids => this.likeIds.set(ids) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/app/_services/members.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; 2 | import { Injectable, inject, model, signal } from '@angular/core'; 3 | import { environment } from '../../environments/environment'; 4 | import { Member } from '../_models/member'; 5 | import { of, tap } from 'rxjs'; 6 | import { Photo } from '../_models/photo'; 7 | import { PaginatedResult } from '../_models/pagination'; 8 | import { UserParams } from '../_models/userParams'; 9 | import { AccountService } from './account.service'; 10 | import { setPaginatedResponse, setPaginationHeaders } from './paginationHelper'; 11 | 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class MembersService { 16 | private http = inject(HttpClient); 17 | private accountService = inject(AccountService); 18 | baseUrl = environment.apiUrl; 19 | paginatedResult = signal | null>(null); 20 | memberCache = new Map(); 21 | user = this.accountService.currentUser(); 22 | userParams = model(new UserParams(this.user)); 23 | 24 | resetUserParams() { 25 | this.userParams.set(new UserParams(this.user)); 26 | } 27 | 28 | getMembers() { 29 | const response = this.memberCache.get(Object.values(this.userParams()).join('-')); 30 | 31 | if (response) return setPaginatedResponse(response, this.paginatedResult); 32 | 33 | let params = setPaginationHeaders(this.userParams().pageNumber, this.userParams().pageSize); 34 | 35 | params = params.append('minAge', this.userParams().minAge); 36 | params = params.append('maxAge', this.userParams().maxAge); 37 | params = params.append('gender', this.userParams().gender); 38 | params = params.append('orderBy', this.userParams().orderBy); 39 | 40 | return this.http.get(this.baseUrl + 'users', {observe: 'response', params}).subscribe({ 41 | next: response => { 42 | setPaginatedResponse(response, this.paginatedResult); 43 | this.memberCache.set(Object.values(this.userParams()).join('-'), response); 44 | } 45 | }) 46 | } 47 | 48 | getMember(username: string) { 49 | const member: Member = [...this.memberCache.values()] 50 | .reduce((arr, elem) => arr.concat(elem.body), []) 51 | .find((m: Member) => m.username === username); 52 | 53 | if (member) return of(member); 54 | 55 | return this.http.get(this.baseUrl + 'users/' + username); 56 | } 57 | 58 | updateMember(member: Member) { 59 | return this.http.put(this.baseUrl + 'users', member).pipe( 60 | // tap(() => { 61 | // this.members.update(members => members.map(m => m.username === member.username 62 | // ? member : m)) 63 | // }) 64 | ) 65 | } 66 | 67 | setMainPhoto(photo: Photo) { 68 | return this.http.put(this.baseUrl + 'users/set-main-photo/' + photo.id, {}).pipe( 69 | // tap(() => { 70 | // this.members.update(members => members.map(m => { 71 | // if (m.photos.includes(photo)) { 72 | // m.photoUrl = photo.url 73 | // } 74 | // return m; 75 | // })) 76 | // }) 77 | ) 78 | } 79 | 80 | deletePhoto(photo: Photo) { 81 | return this.http.delete(this.baseUrl + 'users/delete-photo/' + photo.id).pipe( 82 | // tap(() => { 83 | // this.members.update(members => members.map(m => { 84 | // if (m.photos.includes(photo)) { 85 | // m.photos = m.photos.filter(x => x.id !== photo.id) 86 | // } 87 | // return m 88 | // })) 89 | // }) 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /client/src/app/_services/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, signal } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { PaginatedResult } from '../_models/pagination'; 5 | import { Message } from '../_models/message'; 6 | import { setPaginatedResponse, setPaginationHeaders } from './paginationHelper'; 7 | import { HubConnection, HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr'; 8 | import { User } from '../_models/user'; 9 | import { Group } from '../_models/group'; 10 | import { BusyService } from './busy.service'; 11 | 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class MessageService { 16 | baseUrl = environment.apiUrl; 17 | hubUrl = environment.hubsUrl; 18 | private http = inject(HttpClient); 19 | private busyService = inject(BusyService); 20 | hubConnection?: HubConnection; 21 | paginatedResult = signal | null>(null); 22 | messageThread = signal([]); 23 | 24 | createHubConnection(user: User, otherUsername: string) { 25 | this.busyService.busy(); 26 | this.hubConnection = new HubConnectionBuilder() 27 | .withUrl(this.hubUrl + 'message?user=' + otherUsername, { 28 | accessTokenFactory: () => user.token 29 | }) 30 | .withAutomaticReconnect() 31 | .build(); 32 | 33 | this.hubConnection.start() 34 | .catch(error => console.log(error)) 35 | .finally(() => this.busyService.idle()); 36 | 37 | this.hubConnection.on('ReceiveMessageThread', messages => { 38 | this.messageThread.set(messages) 39 | }); 40 | 41 | this.hubConnection.on('NewMessage', message => { 42 | this.messageThread.update(messages => [...messages, message]) 43 | }); 44 | 45 | this.hubConnection.on('UpdatedGroup', (group: Group) => { 46 | if (group.connections.some(x => x.username === otherUsername)) { 47 | this.messageThread.update(messages => { 48 | messages.forEach(message => { 49 | if (!message.dateRead) { 50 | message.dateRead = new Date(Date.now()); 51 | } 52 | }) 53 | return messages; 54 | }) 55 | } 56 | }) 57 | } 58 | 59 | stopHubConnection() { 60 | if (this.hubConnection?.state === HubConnectionState.Connected) { 61 | this.hubConnection.stop().catch(error => console.log(error)) 62 | } 63 | } 64 | 65 | getMessages(pageNumber: number, pageSize: number, container: string) { 66 | let params = setPaginationHeaders(pageNumber, pageSize); 67 | 68 | params = params.append('Container', container); 69 | 70 | return this.http.get(this.baseUrl + 'messages', {observe: 'response', params}) 71 | .subscribe({ 72 | next: response => setPaginatedResponse(response, this.paginatedResult) 73 | }) 74 | } 75 | 76 | getMessageThread(username: string) { 77 | return this.http.get(this.baseUrl + 'messages/thread/' + username); 78 | } 79 | 80 | async sendMessage(username: string, content: string) { 81 | return this.hubConnection?.invoke('SendMessage', {recipientUsername: username, content}) 82 | } 83 | 84 | deleteMessage(id: number) { 85 | return this.http.delete(this.baseUrl + 'messages/' + id); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/src/app/_services/paginationHelper.ts: -------------------------------------------------------------------------------- 1 | import { HttpParams, HttpResponse } from "@angular/common/http"; 2 | import { signal } from "@angular/core"; 3 | import { PaginatedResult } from "../_models/pagination"; 4 | 5 | export function setPaginatedResponse(response: HttpResponse, 6 | paginatedResultSignal: ReturnType | null>>) { 7 | paginatedResultSignal.set({ 8 | items: response.body as T, 9 | pagination: JSON.parse(response.headers.get('Pagination')!) 10 | }) 11 | } 12 | 13 | export function setPaginationHeaders(pageNumber: number, pageSize: number) { 14 | let params = new HttpParams(); 15 | 16 | if (pageNumber && pageSize) { 17 | params = params.append('pageNumber', pageNumber); 18 | params = params.append('pageSize', pageSize); 19 | } 20 | 21 | return params; 22 | } -------------------------------------------------------------------------------- /client/src/app/_services/presence.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, signal } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | import { HubConnection, HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr'; 4 | import { ToastrService } from 'ngx-toastr'; 5 | import { User } from '../_models/user'; 6 | import { take } from 'rxjs'; 7 | import { Router } from '@angular/router'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class PresenceService { 13 | hubUrl = environment.hubsUrl; 14 | private hubConnection?: HubConnection; 15 | private toastr = inject(ToastrService); 16 | private router = inject(Router); 17 | onlineUsers = signal([]); 18 | 19 | createHubConnection(user: User) { 20 | this.hubConnection = new HubConnectionBuilder() 21 | .withUrl(this.hubUrl + 'presence', { 22 | accessTokenFactory: () => user.token 23 | }) 24 | .withAutomaticReconnect() 25 | .build(); 26 | 27 | this.hubConnection.start().catch(error => console.log(error)); 28 | 29 | this.hubConnection.on('UserIsOnline', username => { 30 | this.onlineUsers.update(users => [...users, username]); 31 | }); 32 | 33 | this.hubConnection.on('UserIsOffline', username => { 34 | this.onlineUsers.update(users => users.filter(x => x !== username)); 35 | }); 36 | 37 | this.hubConnection.on('GetOnlineUsers', usernames => { 38 | this.onlineUsers.set(usernames) 39 | }); 40 | 41 | this.hubConnection.on('NewMessageReceived', ({username, knownAs}) => { 42 | this.toastr.info(knownAs + ' has sent you a new message! Click me to see it') 43 | .onTap 44 | .pipe(take(1)) 45 | .subscribe(() => this.router.navigateByUrl('/members/' + username + '?tab=Messages')) 46 | }) 47 | } 48 | 49 | stopHubConnection() { 50 | if (this.hubConnection?.state === HubConnectionState.Connected) { 51 | this.hubConnection.stop().catch(error => console.log(error)) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/app/admin/admin-panel/admin-panel.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/admin/admin-panel/admin-panel.component.css -------------------------------------------------------------------------------- /client/src/app/admin/admin-panel/admin-panel.component.html: -------------------------------------------------------------------------------- 1 |

Admin panel

2 |
3 | 4 | 5 |
6 | 7 |
8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /client/src/app/admin/admin-panel/admin-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TabsModule } from 'ngx-bootstrap/tabs'; 3 | import { UserManagementComponent } from "../user-management/user-management.component"; 4 | import { HasRoleDirective } from '../../_directives/has-role.directive'; 5 | import { PhotoManagementComponent } from "../photo-management/photo-management.component"; 6 | 7 | @Component({ 8 | selector: 'app-admin-panel', 9 | standalone: true, 10 | templateUrl: './admin-panel.component.html', 11 | styleUrl: './admin-panel.component.css', 12 | imports: [TabsModule, UserManagementComponent, HasRoleDirective, PhotoManagementComponent] 13 | }) 14 | export class AdminPanelComponent { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/admin/photo-management/photo-management.component.css: -------------------------------------------------------------------------------- 1 | img.img-thumbnail { 2 | height: 175px; 3 | min-width: 175px !important; 4 | margin-bottom: 2px; 5 | } 6 | 7 | .not-approved { 8 | opacity: 0.2; 9 | } 10 | 11 | .img-wrapper { 12 | position: relative 13 | } 14 | 15 | .img-text { 16 | position: absolute; 17 | left: 0; 18 | right: 0; 19 | margin-left: auto; 20 | margin-right: auto; 21 | bottom: 30%; 22 | } -------------------------------------------------------------------------------- /client/src/app/admin/photo-management/photo-management.component.html: -------------------------------------------------------------------------------- 1 |
2 | @for (photo of photos; track photo.id) { 3 |
4 |

{{photo.username}}

5 | {{photo.username}} 9 |
10 | 11 | 12 |
13 |
14 | } 15 |
16 | -------------------------------------------------------------------------------- /client/src/app/admin/photo-management/photo-management.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { Photo } from '../../_models/photo'; 3 | import { AdminService } from '../../_services/admin.service'; 4 | 5 | @Component({ 6 | selector: 'app-photo-management', 7 | standalone: true, 8 | imports: [], 9 | templateUrl: './photo-management.component.html', 10 | styleUrl: './photo-management.component.css' 11 | }) 12 | export class PhotoManagementComponent implements OnInit { 13 | photos: Photo[] = []; 14 | private adminService = inject(AdminService); 15 | 16 | ngOnInit(): void { 17 | this.getPhotosForApproval(); 18 | } 19 | 20 | getPhotosForApproval() { 21 | this.adminService.getPhotosForApproval().subscribe({ 22 | next: photos => this.photos = photos 23 | }) 24 | } 25 | 26 | approvePhoto(photoId: number) { 27 | this.adminService.approvePhoto(photoId).subscribe({ 28 | next: () => this.photos.splice(this.photos.findIndex(p => p.id === photoId), 1) 29 | }) 30 | } 31 | 32 | rejectPhoto(photoId: number) { 33 | this.adminService.rejectPhoto(photoId).subscribe({ 34 | next: () => this.photos.splice(this.photos.findIndex(p => p.id === photoId), 1) 35 | }) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /client/src/app/admin/user-management/user-management.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/admin/user-management/user-management.component.css -------------------------------------------------------------------------------- /client/src/app/admin/user-management/user-management.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | @for (user of users; track user.username) { 12 | 13 | 14 | 15 | 16 | 17 | } 18 | 19 | 20 |
UsernameActive roles
{{user.username}}{{user.roles}}
21 |
22 | -------------------------------------------------------------------------------- /client/src/app/admin/user-management/user-management.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { AdminService } from '../../_services/admin.service'; 3 | import { User } from '../../_models/user'; 4 | import { BsModalRef, BsModalService, ModalOptions } from 'ngx-bootstrap/modal'; 5 | import { RolesModalComponent } from '../../modals/roles-modal/roles-modal.component'; 6 | 7 | @Component({ 8 | selector: 'app-user-management', 9 | standalone: true, 10 | imports: [], 11 | templateUrl: './user-management.component.html', 12 | styleUrl: './user-management.component.css' 13 | }) 14 | export class UserManagementComponent implements OnInit { 15 | private adminService = inject(AdminService); 16 | private modalService = inject(BsModalService); 17 | users: User[] = []; 18 | 19 | bsModalRef: BsModalRef = new BsModalRef(); 20 | 21 | ngOnInit(): void { 22 | this.getUsersWithRoles(); 23 | } 24 | 25 | openRolesModal(user: User) { 26 | const initialState: ModalOptions = { 27 | class: 'modal-lg', 28 | initialState: { 29 | title: 'User roles', 30 | username: user.username, 31 | selectedRoles: [...user.roles], 32 | availableRoles: ['Admin', 'Moderator', 'Member'], 33 | users: this.users, 34 | rolesUpdated: false 35 | } 36 | } 37 | this.bsModalRef = this.modalService.show(RolesModalComponent, initialState); 38 | this.bsModalRef.onHide?.subscribe({ 39 | next: () => { 40 | if (this.bsModalRef.content && this.bsModalRef.content.rolesUpdated) { 41 | const selectedRoles = this.bsModalRef.content.selectedRoles; 42 | this.adminService.updateUserRoles(user.username, selectedRoles).subscribe({ 43 | next: roles => user.roles = roles 44 | }) 45 | } 46 | } 47 | }) 48 | } 49 | 50 | getUsersWithRoles() { 51 | this.adminService.getUserWithRoles().subscribe({ 52 | next: users => this.users = users 53 | }) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /client/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/app.component.css -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Loading...

3 |
4 | 5 | 6 | 7 |
8 | 9 |
10 | -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | import { NavComponent } from "./nav/nav.component"; 4 | import { AccountService } from './_services/account.service'; 5 | import { HomeComponent } from "./home/home.component"; 6 | import { NgxSpinnerComponent } from 'ngx-spinner'; 7 | 8 | @Component({ 9 | selector: 'app-root', 10 | standalone: true, 11 | templateUrl: './app.component.html', 12 | styleUrl: './app.component.css', 13 | imports: [RouterOutlet, NavComponent, HomeComponent, NgxSpinnerComponent] 14 | }) 15 | export class AppComponent implements OnInit { 16 | private accountService = inject(AccountService); 17 | 18 | ngOnInit(): void { 19 | this.setCurrentUser(); 20 | } 21 | 22 | setCurrentUser() { 23 | const userString = localStorage.getItem('user'); 24 | if (!userString) return; 25 | const user = JSON.parse(userString); 26 | this.accountService.setCurrentUser(user); 27 | } 28 | 29 | 30 | } 31 | -------------------------------------------------------------------------------- /client/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, importProvidersFrom } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | import { provideAnimations } from '@angular/platform-browser/animations'; 4 | 5 | import { routes } from './app.routes'; 6 | import { provideHttpClient, withInterceptors } from '@angular/common/http'; 7 | import { provideToastr } from 'ngx-toastr'; 8 | import { errorInterceptor } from './_interceptors/error.interceptor'; 9 | import { jwtInterceptor } from './_interceptors/jwt.interceptor'; 10 | import { NgxSpinnerModule } from 'ngx-spinner'; 11 | import { loadingInterceptor } from './_interceptors/loading.interceptor'; 12 | import { TimeagoModule } from 'ngx-timeago'; 13 | import { ModalModule } from 'ngx-bootstrap/modal'; 14 | 15 | export const appConfig: ApplicationConfig = { 16 | providers: [ 17 | provideRouter(routes), 18 | provideHttpClient(withInterceptors([errorInterceptor, jwtInterceptor, loadingInterceptor])), 19 | provideAnimations(), 20 | provideToastr({ 21 | positionClass: 'toast-bottom-right' 22 | }), 23 | importProvidersFrom(NgxSpinnerModule, TimeagoModule.forRoot(), ModalModule.forRoot()) 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { HomeComponent } from './home/home.component'; 3 | import { MemberListComponent } from './members/member-list/member-list.component'; 4 | import { MemberDetailComponent } from './members/member-detail/member-detail.component'; 5 | import { ListsComponent } from './lists/lists.component'; 6 | import { MessagesComponent } from './messages/messages.component'; 7 | import { authGuard } from './_guards/auth.guard'; 8 | import { TestErrorsComponent } from './errors/test-errors/test-errors.component'; 9 | import { NotFoundComponent } from './errors/not-found/not-found.component'; 10 | import { ServerErrorComponent } from './errors/server-error/server-error.component'; 11 | import { MemberEditComponent } from './members/member-edit/member-edit.component'; 12 | import { preventUnsavedChangesGuard } from './_guards/prevent-unsaved-changes.guard'; 13 | import { memberDetailedResolver } from './_resolvers/member-detailed.resolver'; 14 | import { AdminPanelComponent } from './admin/admin-panel/admin-panel.component'; 15 | import { adminGuard } from './_guards/admin.guard'; 16 | 17 | export const routes: Routes = [ 18 | {path: '', component: HomeComponent}, 19 | { 20 | path: '', 21 | runGuardsAndResolvers: 'always', 22 | canActivate: [authGuard], 23 | children: [ 24 | {path: 'members', component: MemberListComponent}, 25 | {path: 'members/:username', component: MemberDetailComponent, 26 | resolve: {member: memberDetailedResolver}}, 27 | {path: 'member/edit', component: MemberEditComponent, 28 | canDeactivate: [preventUnsavedChangesGuard]}, 29 | {path: 'lists', component: ListsComponent}, 30 | {path: 'messages', component: MessagesComponent}, 31 | {path: 'admin', component: AdminPanelComponent, canActivate: [adminGuard]} 32 | ] 33 | }, 34 | {path: 'errors', component: TestErrorsComponent}, 35 | {path: 'not-found', component: NotFoundComponent}, 36 | {path: 'server-error', component: ServerErrorComponent}, 37 | {path: '**', component: HomeComponent, pathMatch: 'full'}, 38 | ]; 39 | -------------------------------------------------------------------------------- /client/src/app/errors/not-found/not-found.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/errors/not-found/not-found.component.css -------------------------------------------------------------------------------- /client/src/app/errors/not-found/not-found.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Not found

3 | 4 |
5 | -------------------------------------------------------------------------------- /client/src/app/errors/not-found/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-not-found', 6 | standalone: true, 7 | imports: [RouterLink], 8 | templateUrl: './not-found.component.html', 9 | styleUrl: './not-found.component.css' 10 | }) 11 | export class NotFoundComponent { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /client/src/app/errors/server-error/server-error.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/errors/server-error/server-error.component.css -------------------------------------------------------------------------------- /client/src/app/errors/server-error/server-error.component.html: -------------------------------------------------------------------------------- 1 |

Internal Server Error

2 | @if (error) { 3 |
Error: {{error.message}}
4 |

Note: If you are seeing this error then angular is not to blame!

5 |

What to do next?

6 |
    7 |
  1. Open chrome dev tools and check the failing network request in the network tab
  2. 8 |
  3. Examine the URL of the failing request
  4. 9 |
  5. Reproduce the error in postman - if you can reproduce the error then angular is not to blame
  6. 10 |
11 |

Following is the stack trace - check the first 2 12 | lines this tells you exactly which line of code caused the problem!

13 | 14 | {{error.details}} 15 | 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/errors/server-error/server-error.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-server-error', 6 | standalone: true, 7 | imports: [], 8 | templateUrl: './server-error.component.html', 9 | styleUrl: './server-error.component.css' 10 | }) 11 | export class ServerErrorComponent { 12 | error: any; 13 | 14 | constructor(private router: Router) { 15 | const navigation = this.router.getCurrentNavigation(); 16 | this.error = navigation?.extras?.state?.['error']; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /client/src/app/errors/test-errors/test-errors.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/errors/test-errors/test-errors.component.css -------------------------------------------------------------------------------- /client/src/app/errors/test-errors/test-errors.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 9 | 10 | @if (validationErrors.length > 0) { 11 |
12 |
    13 | @for (error of validationErrors; track $index) { 14 |
  • {{error}}
  • 15 | } 16 |
17 |
18 | } 19 | 20 |
21 | -------------------------------------------------------------------------------- /client/src/app/errors/test-errors/test-errors.component.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Component, inject } from '@angular/core'; 3 | import { environment } from '../../../environments/environment'; 4 | 5 | @Component({ 6 | selector: 'app-test-errors', 7 | standalone: true, 8 | imports: [], 9 | templateUrl: './test-errors.component.html', 10 | styleUrl: './test-errors.component.css' 11 | }) 12 | export class TestErrorsComponent { 13 | baseUrl = environment.apiUrl; 14 | private http = inject(HttpClient); 15 | validationErrors: string[] = []; 16 | 17 | get400Error() { 18 | this.http.get(this.baseUrl + 'buggy/bad-request').subscribe({ 19 | next: response => console.log(response), 20 | error: error => console.log(error) 21 | }) 22 | } 23 | 24 | get401Error() { 25 | this.http.get(this.baseUrl + 'buggy/auth').subscribe({ 26 | next: response => console.log(response), 27 | error: error => console.log(error) 28 | }) 29 | } 30 | 31 | get404Error() { 32 | this.http.get(this.baseUrl + 'buggy/not-found').subscribe({ 33 | next: response => console.log(response), 34 | error: error => console.log(error) 35 | }) 36 | } 37 | 38 | get500Error() { 39 | this.http.get(this.baseUrl + 'buggy/server-error').subscribe({ 40 | next: response => console.log(response), 41 | error: error => console.log(error) 42 | }) 43 | } 44 | 45 | get400ValidationError() { 46 | this.http.post(this.baseUrl + 'account/register', {}).subscribe({ 47 | next: response => console.log(response), 48 | error: error => { 49 | console.log(error); 50 | this.validationErrors = error; 51 | } 52 | }) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /client/src/app/home/home.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/home/home.component.css -------------------------------------------------------------------------------- /client/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | @if (!registerMode) { 5 |

Find your match

6 |

Come on in to view your matches... all you need to do is sign up!

7 |
8 | 9 | 10 |
11 | } @else { 12 |
13 |
14 |
15 | 18 |
19 |
20 |
21 | } 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /client/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RegisterComponent } from "../register/register.component"; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | standalone: true, 7 | templateUrl: './home.component.html', 8 | styleUrl: './home.component.css', 9 | imports: [RegisterComponent] 10 | }) 11 | export class HomeComponent { 12 | registerMode = false; 13 | 14 | registerToggle() { 15 | this.registerMode = !this.registerMode 16 | } 17 | 18 | cancelRegisterMode(event: boolean) { 19 | this.registerMode = event; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/app/lists/lists.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/lists/lists.component.css -------------------------------------------------------------------------------- /client/src/app/lists/lists.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{getTitle()}}

3 |
4 | 5 |
6 |
7 |
8 | 10 | 12 | 14 |
15 |
16 | 17 |
18 | @for (member of likesService.paginatedResult()?.items; track member.id) { 19 |
20 | 21 |
22 | } 23 |
24 |
25 | 26 | @if (likesService.paginatedResult()?.pagination) { 27 |
28 | 39 | 40 |
41 | 42 | } -------------------------------------------------------------------------------- /client/src/app/lists/lists.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit, inject } from '@angular/core'; 2 | import { LikesService } from '../_services/likes.service'; 3 | import { ButtonsModule } from 'ngx-bootstrap/buttons'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { MemberCardComponent } from "../members/member-card/member-card.component"; 6 | import { PaginationModule } from 'ngx-bootstrap/pagination'; 7 | 8 | @Component({ 9 | selector: 'app-lists', 10 | standalone: true, 11 | templateUrl: './lists.component.html', 12 | styleUrl: './lists.component.css', 13 | imports: [ButtonsModule, FormsModule, MemberCardComponent, PaginationModule] 14 | }) 15 | export class ListsComponent implements OnInit, OnDestroy { 16 | likesService = inject(LikesService); 17 | predicate = 'liked'; 18 | pageNumber = 1; 19 | pageSize = 5; 20 | 21 | ngOnInit(): void { 22 | this.loadLikes(); 23 | } 24 | 25 | getTitle() { 26 | switch (this.predicate) { 27 | case 'liked': return 'Members you like'; 28 | case 'likedBy': return 'Members who like you'; 29 | default: return 'Mutual' 30 | } 31 | } 32 | 33 | loadLikes() { 34 | this.likesService.getLikes(this.predicate, this.pageNumber, this.pageSize); 35 | } 36 | 37 | pageChanged(event: any) { 38 | if (this.pageNumber !== event.page) { 39 | this.pageNumber = event.page; 40 | this.loadLikes(); 41 | } 42 | } 43 | 44 | ngOnDestroy(): void { 45 | this.likesService.paginatedResult.set(null); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /client/src/app/members/member-card/member-card.component.css: -------------------------------------------------------------------------------- 1 | .card:hover img { 2 | transform: scale(1.2,1.2); 3 | transition-duration: 500ms; 4 | transition-timing-function: ease-out; 5 | } 6 | 7 | .card img { 8 | transform: scale(1.0,1.0); 9 | transition-duration: 500ms; 10 | transition-timing-function: ease-out; 11 | } 12 | 13 | .card-img-wrapper { 14 | overflow: hidden; 15 | position: relative; 16 | } 17 | 18 | .member-icons { 19 | position: absolute; 20 | bottom: -30%; 21 | left: 0; 22 | right: 0; 23 | margin-right: auto; 24 | margin-left: auto; 25 | opacity: 0; 26 | } 27 | 28 | .card-img-wrapper:hover .member-icons { 29 | bottom: 0; 30 | opacity: 1; 31 | } 32 | 33 | .animate { 34 | transition: all 0.3s ease-in-out; 35 | } 36 | 37 | @keyframes fa-blink { 38 | 0% {opacity: 1;} 39 | 100% {opacity: 0.4;} 40 | } 41 | 42 | .is-online { 43 | animation: fa-blink 1.5s linear infinite; 44 | color: rgb(1, 189, 42) 45 | } -------------------------------------------------------------------------------- /client/src/app/members/member-card/member-card.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{member().knownAs}} 4 |
    5 |
  • 6 | 7 |
  • 8 |
  • 9 | 10 |
  • 11 |
  • 12 | 16 |
  • 17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 | 25 | {{member().knownAs}}, {{member().age}} 26 | @if (hasLiked()) { 27 | 28 | } 29 |
30 |

{{member().city}}

31 |
32 |
33 | -------------------------------------------------------------------------------- /client/src/app/members/member-card/member-card.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject, input } from '@angular/core'; 2 | import { Member } from '../../_models/member'; 3 | import { RouterLink } from '@angular/router'; 4 | import { LikesService } from '../../_services/likes.service'; 5 | import { PresenceService } from '../../_services/presence.service'; 6 | 7 | @Component({ 8 | selector: 'app-member-card', 9 | standalone: true, 10 | imports: [RouterLink], 11 | templateUrl: './member-card.component.html', 12 | styleUrl: './member-card.component.css' 13 | }) 14 | export class MemberCardComponent { 15 | private likeService = inject(LikesService); 16 | private presenceService = inject(PresenceService); 17 | member = input.required(); 18 | hasLiked = computed(() => this.likeService.likeIds().includes(this.member().id)); 19 | isOnline = computed(() => this.presenceService.onlineUsers().includes(this.member().username)); 20 | 21 | toggleLike() { 22 | this.likeService.toggleLike(this.member().id).subscribe({ 23 | next: () => { 24 | if (this.hasLiked()) { 25 | this.likeService.likeIds.update(ids => ids.filter(x => x !== this.member().id)) 26 | } else { 27 | this.likeService.likeIds.update(ids => [...ids, this.member().id]) 28 | } 29 | } 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/app/members/member-detail/member-detail.component.css: -------------------------------------------------------------------------------- 1 | .img-thumbnail { 2 | margin: 25px; 3 | width: 85%; 4 | height: 85%; 5 | } 6 | 7 | .card-body { 8 | padding: 0 25px; 9 | } 10 | 11 | .card-footer { 12 | padding: 10px 15px; 13 | background-color: #fff; 14 | border-top: none; 15 | } -------------------------------------------------------------------------------- /client/src/app/members/member-detail/member-detail.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{member.knownAs}} 6 |
7 | @if (presenceService.onlineUsers().includes(member.username)) { 8 |
9 | Online now 10 |
11 | } 12 | 13 |
14 | Location: 15 |

{{member.city}}, {{member.country}}

16 |
17 |
18 | Age: 19 |

{{member.age}}

20 |
21 |
22 | Last Active: 23 |

{{member.lastActive | timeago}}

24 |
25 |
26 | Member since: 27 |

{{member.created | date: 'dd MMM yyyy'}}

28 |
29 |
30 | 36 |
37 |
38 | 39 |
40 | 41 | 42 |

Description

43 |

{{member.introduction}}

44 |

Looking for

45 |

{{member.lookingFor}}

46 |
47 | 48 |

Interests

49 |

{{member.interests}}

50 |
51 | 52 | @if (photoTab.active) { 53 | 54 | } 55 | 56 | 57 | 58 | 61 | 62 |
63 |
64 | 65 |
-------------------------------------------------------------------------------- /client/src/app/members/member-detail/member-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | import { Member } from '../../_models/member'; 4 | import { TabDirective, TabsModule, TabsetComponent } from 'ngx-bootstrap/tabs'; 5 | import { GalleryItem, GalleryModule, ImageItem } from 'ng-gallery'; 6 | import { TimeagoModule } from 'ngx-timeago'; 7 | import { DatePipe } from '@angular/common'; 8 | import { MemberMessagesComponent } from "../member-messages/member-messages.component"; 9 | import { Message } from '../../_models/message'; 10 | import { MessageService } from '../../_services/message.service'; 11 | import { PresenceService } from '../../_services/presence.service'; 12 | import { AccountService } from '../../_services/account.service'; 13 | import { HubConnectionState } from '@microsoft/signalr'; 14 | 15 | @Component({ 16 | selector: 'app-member-detail', 17 | standalone: true, 18 | templateUrl: './member-detail.component.html', 19 | styleUrl: './member-detail.component.css', 20 | imports: [TabsModule, GalleryModule, TimeagoModule, DatePipe, MemberMessagesComponent] 21 | }) 22 | export class MemberDetailComponent implements OnInit, OnDestroy { 23 | @ViewChild('memberTabs', {static: true}) memberTabs?: TabsetComponent; 24 | private messageService = inject(MessageService); 25 | private accountService = inject(AccountService); 26 | presenceService = inject(PresenceService); 27 | private route = inject(ActivatedRoute); 28 | private router = inject(Router); 29 | member: Member = {} as Member; 30 | images: GalleryItem[] = []; 31 | activeTab?: TabDirective; 32 | 33 | ngOnInit(): void { 34 | this.route.data.subscribe({ 35 | next: data => { 36 | this.member = data['member']; 37 | this.member && this.member.photos.map(p => { 38 | this.images.push(new ImageItem({src: p.url, thumb: p.url})) 39 | }) 40 | } 41 | }) 42 | 43 | this.route.paramMap.subscribe({ 44 | next: _ => this.onRouteParamsChange() 45 | }) 46 | 47 | this.route.queryParams.subscribe({ 48 | next: params => { 49 | params['tab'] && this.selectTab(params['tab']) 50 | } 51 | }) 52 | } 53 | 54 | selectTab(heading: string) { 55 | if (this.memberTabs) { 56 | const messageTab = this.memberTabs.tabs.find(x => x.heading === heading); 57 | if (messageTab) messageTab.active = true; 58 | } 59 | } 60 | 61 | onRouteParamsChange() { 62 | const user = this.accountService.currentUser(); 63 | if (!user) return; 64 | if (this.messageService.hubConnection?.state === HubConnectionState.Connected && this.activeTab?.heading === 'Messages') { 65 | this.messageService.hubConnection.stop().then(() => { 66 | this.messageService.createHubConnection(user, this.member.username); 67 | }) 68 | } 69 | } 70 | 71 | onTabActivated(data: TabDirective) { 72 | this.activeTab = data; 73 | this.router.navigate([], { 74 | relativeTo: this.route, 75 | queryParams: {tab: this.activeTab.heading}, 76 | queryParamsHandling: 'merge' 77 | }) 78 | if (this.activeTab.heading === 'Messages' && this.member) { 79 | const user = this.accountService.currentUser(); 80 | if (!user) return; 81 | this.messageService.createHubConnection(user, this.member.username); 82 | } else { 83 | this.messageService.stopHubConnection(); 84 | } 85 | } 86 | 87 | ngOnDestroy(): void { 88 | this.messageService.stopHubConnection(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src/app/members/member-edit/member-edit.component.css: -------------------------------------------------------------------------------- 1 | .img-thumbnail { 2 | margin: 25px; 3 | width: 85%; 4 | height: 85%; 5 | } 6 | 7 | .card-body { 8 | padding: 0 25px; 9 | } 10 | 11 | .card-footer { 12 | padding: 10px 15px; 13 | background-color: #fff; 14 | border-top: none; 15 | } -------------------------------------------------------------------------------- /client/src/app/members/member-edit/member-edit.component.html: -------------------------------------------------------------------------------- 1 | @if (member) { 2 |
3 |
4 |

Your profile

5 |
6 |
7 | @if (editForm.dirty) { 8 |
9 |

Information: 10 | You have made changes. Any unsaved changes will be lost

11 |
12 | } 13 |
14 |
15 |
16 | {{member.knownAs}} 21 |
22 |
23 | Location: 24 |

{{member.city}}, {{member.country}}

25 |
26 |
27 | Age: 28 |

{{member.age}}

29 |
30 |
31 | Last Active: 32 |

{{member.lastActive | timeago}}

33 |
34 |
35 | Member since: 36 |

{{member.created | date: 'longDate'}}

37 |
38 |
39 | 47 |
48 |
49 | 50 |
51 | 52 | 53 |
54 |

Description

55 | 62 |

Looking for

63 | 70 |

Interests

71 | 78 | 79 |

Location Details

80 |
81 | 82 | 87 | 88 | 93 |
94 | 95 |
96 | 97 | 98 |
99 | 100 | 104 | 105 |
106 |
107 | 108 |
109 | } 110 | -------------------------------------------------------------------------------- /client/src/app/members/member-edit/member-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostListener, OnInit, ViewChild, inject } from '@angular/core'; 2 | import { Member } from '../../_models/member'; 3 | import { AccountService } from '../../_services/account.service'; 4 | import { MembersService } from '../../_services/members.service'; 5 | import { TabsModule } from 'ngx-bootstrap/tabs'; 6 | import { FormsModule, NgForm } from '@angular/forms'; 7 | import { ToastrService } from 'ngx-toastr'; 8 | import { PhotoEditorComponent } from "../photo-editor/photo-editor.component"; 9 | import { DatePipe } from '@angular/common'; 10 | import { TimeagoModule } from 'ngx-timeago'; 11 | 12 | @Component({ 13 | selector: 'app-member-edit', 14 | standalone: true, 15 | templateUrl: './member-edit.component.html', 16 | styleUrl: './member-edit.component.css', 17 | imports: [TabsModule, FormsModule, PhotoEditorComponent, DatePipe, TimeagoModule] 18 | }) 19 | export class MemberEditComponent implements OnInit { 20 | @ViewChild('editForm') editForm?: NgForm; 21 | @HostListener('window:beforeunload', ['$event']) notify($event:any) { 22 | if (this.editForm?.dirty) { 23 | $event.returnValue = true; 24 | } 25 | } 26 | 27 | member?: Member; 28 | private accountService = inject(AccountService); 29 | private memberService = inject(MembersService); 30 | private toastr = inject(ToastrService); 31 | 32 | ngOnInit(): void { 33 | this.loadMember(); 34 | } 35 | 36 | loadMember() { 37 | const user = this.accountService.currentUser(); 38 | if (!user) return; 39 | this.memberService.getMember(user.username).subscribe({ 40 | next: member => this.member = member 41 | }) 42 | } 43 | 44 | updateMember() { 45 | this.memberService.updateMember(this.editForm?.value).subscribe({ 46 | next: _ => { 47 | this.toastr.success('Profile updated successfully'); 48 | this.editForm?.reset(this.member); 49 | } 50 | }) 51 | } 52 | 53 | onMemberChange(event: Member) { 54 | this.member = event; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/app/members/member-list/member-list.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/members/member-list/member-list.component.css -------------------------------------------------------------------------------- /client/src/app/members/member-list/member-list.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

Your matches - {{memberService.paginatedResult()?.pagination?.totalItems}}

5 |
6 | 7 |
8 |
9 |
10 | 11 | 17 |
18 |
19 | 20 | 26 |
27 | 28 |
29 | 30 | 40 |
41 | 42 | 43 | 44 | 45 |
46 |
47 | 55 | 63 |
64 |
65 | 66 |
67 |
68 | 69 | @for (member of memberService.paginatedResult()?.items; track member.id) { 70 |
71 | 72 |
73 | } 74 | 75 |
76 | 77 | @if (memberService.paginatedResult()?.pagination) { 78 |
79 | 90 | 91 |
92 | 93 | } 94 | 95 | -------------------------------------------------------------------------------- /client/src/app/members/member-list/member-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { MembersService } from '../../_services/members.service'; 3 | import { MemberCardComponent } from "../member-card/member-card.component"; 4 | import { PaginationModule } from 'ngx-bootstrap/pagination'; 5 | import { AccountService } from '../../_services/account.service'; 6 | import { UserParams } from '../../_models/userParams'; 7 | import { FormsModule } from '@angular/forms'; 8 | import { ButtonsModule } from 'ngx-bootstrap/buttons'; 9 | 10 | @Component({ 11 | selector: 'app-member-list', 12 | standalone: true, 13 | templateUrl: './member-list.component.html', 14 | styleUrl: './member-list.component.css', 15 | imports: [MemberCardComponent, PaginationModule, FormsModule, ButtonsModule] 16 | }) 17 | export class MemberListComponent implements OnInit { 18 | memberService = inject(MembersService); 19 | genderList = [{value: 'male', display: 'Males'}, {value: 'female', display: 'Females'}] 20 | 21 | ngOnInit(): void { 22 | if (!this.memberService.paginatedResult()) this.loadMembers(); 23 | } 24 | 25 | loadMembers() { 26 | this.memberService.getMembers(); 27 | } 28 | 29 | resetFilters() { 30 | this.memberService.resetUserParams(); 31 | this.loadMembers(); 32 | } 33 | 34 | pageChanged(event: any) { 35 | if (this.memberService.userParams().pageNumber != event.page) { 36 | this.memberService.userParams().pageNumber = event.page; 37 | this.loadMembers(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/app/members/member-messages/member-messages.component.css: -------------------------------------------------------------------------------- 1 | .card { 2 | border: none; 3 | } 4 | 5 | .chat { 6 | list-style: none; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .chat li { 12 | margin-bottom: 10px; 13 | padding-bottom: 10px; 14 | border-bottom: 1px dotted #B3A9A9; 15 | } 16 | 17 | .rounded-circle { 18 | max-height: 50px; 19 | } -------------------------------------------------------------------------------- /client/src/app/members/member-messages/member-messages.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @if (messageService.messageThread().length === 0) { 4 |

No messages yet

5 | } @else { 6 |
    11 | @for (message of messageService.messageThread(); track message.id) { 12 |
  • 13 |
    14 | 15 | Image of message sender 19 | 20 |
    21 |
    22 | 23 | {{message.messageSent | timeago}} 24 | @if (!message.dateRead && message.senderUsername !== username()) { 25 | (unread) 26 | } 27 | @if (message.dateRead && message.senderUsername !== username()) { 28 | (read {{message.dateRead | timeago}}) 29 | } 30 | 31 |
    32 |

    {{message.content}}

    33 |
    34 |
    35 |
  • 36 | } 37 | 38 |
39 | } 40 |
41 | 42 | 63 | 64 |
65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /client/src/app/members/member-messages/member-messages.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewChecked, Component, ViewChild, inject, input } from '@angular/core'; 2 | import { MessageService } from '../../_services/message.service'; 3 | import { TimeagoModule } from 'ngx-timeago'; 4 | import { FormsModule, NgForm } from '@angular/forms'; 5 | 6 | @Component({ 7 | selector: 'app-member-messages', 8 | standalone: true, 9 | imports: [TimeagoModule, FormsModule], 10 | templateUrl: './member-messages.component.html', 11 | styleUrl: './member-messages.component.css' 12 | }) 13 | export class MemberMessagesComponent implements AfterViewChecked { 14 | @ViewChild('messageForm') messageForm?: NgForm; 15 | @ViewChild('scrollMe') scrollContainer?: any; 16 | messageService = inject(MessageService); 17 | username = input.required(); 18 | messageContent = ''; 19 | loading = false; 20 | 21 | sendMessage() { 22 | this.loading = true; 23 | this.messageService.sendMessage(this.username(), this.messageContent).then(() => { 24 | this.messageForm?.reset(); 25 | this.scrollToBottom(); 26 | }).finally(() => this.loading = false); 27 | } 28 | 29 | ngAfterViewChecked(): void { 30 | this.scrollToBottom(); 31 | } 32 | 33 | private scrollToBottom() { 34 | if (this.scrollContainer) { 35 | this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/app/members/photo-editor/photo-editor.component.css: -------------------------------------------------------------------------------- 1 | .nv-file-over { 2 | border: dotted 3px red 3 | } 4 | 5 | .not-approved { 6 | opacity: 0.2; 7 | } 8 | 9 | .img-wrapper { 10 | position: relative 11 | } 12 | 13 | .img-text { 14 | position: absolute; 15 | left: 0; 16 | right: 0; 17 | margin-left: auto; 18 | margin-right: auto; 19 | bottom: 30%; 20 | text-align: center; 21 | word-wrap: break-word; 22 | white-space: normal; 23 | width: 100%; 24 | padding: 0 10px; 25 | box-sizing: border-box; 26 | } -------------------------------------------------------------------------------- /client/src/app/members/photo-editor/photo-editor.component.html: -------------------------------------------------------------------------------- 1 |
2 | @for (photo of member().photos; track photo.id) { 3 |
4 | photo of user 10 | @if (!photo.isApproved) { 11 |
12 | Awaiting approval 13 |
14 | } 15 |
16 | 22 | 27 |
28 |
29 | } 30 |
31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 |

Add Photos

39 | 40 |
45 | 46 | Drop photos here 47 |
48 |
49 | 50 |
51 | 52 |

Upload queue

53 |

Queue length: {{ uploader?.queue?.length }}

54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
NameSize
{{ item?.file?.name }}{{ item?.file?.size/1024/1024 | number:'.2' }} MB
69 | 70 |
71 |
72 | Queue progress: 73 |
74 |
75 |
76 |
77 | 81 | 85 | 89 |
90 | 91 |
92 | 93 |
94 | -------------------------------------------------------------------------------- /client/src/app/members/photo-editor/photo-editor.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit, inject, input, output} from '@angular/core'; 2 | import { Member } from '../../_models/member'; 3 | import { DecimalPipe, NgClass, NgFor, NgIf, NgStyle } from '@angular/common'; 4 | import { FileUploadModule, FileUploader } from 'ng2-file-upload'; 5 | import { AccountService } from '../../_services/account.service'; 6 | import { environment } from '../../../environments/environment'; 7 | import { Photo } from '../../_models/photo'; 8 | import { MembersService } from '../../_services/members.service'; 9 | 10 | @Component({ 11 | selector: 'app-photo-editor', 12 | standalone: true, 13 | imports: [NgIf, NgFor, NgStyle, NgClass, FileUploadModule, DecimalPipe], 14 | templateUrl: './photo-editor.component.html', 15 | styleUrl: './photo-editor.component.css' 16 | }) 17 | export class PhotoEditorComponent implements OnInit { 18 | private accountService = inject(AccountService); 19 | private memberService = inject(MembersService); 20 | member = input.required(); 21 | uploader?: FileUploader; 22 | hasBaseDropZoneOver = false; 23 | baseUrl = environment.apiUrl; 24 | memberChange = output(); 25 | 26 | ngOnInit(): void { 27 | this.initializeUploader(); 28 | } 29 | 30 | fileOverBase(e: any) { 31 | this.hasBaseDropZoneOver = e; 32 | } 33 | 34 | deletePhoto(photo: Photo) { 35 | this.memberService.deletePhoto(photo).subscribe({ 36 | next: _ => { 37 | const updatedMember = {...this.member()}; 38 | updatedMember.photos = updatedMember.photos.filter(x => x.id !== photo.id); 39 | this.memberChange.emit(updatedMember); 40 | } 41 | }) 42 | } 43 | 44 | setMainPhoto(photo: Photo) { 45 | this.memberService.setMainPhoto(photo).subscribe({ 46 | next: _ => { 47 | const user = this.accountService.currentUser(); 48 | if (user) { 49 | user.photoUrl = photo.url; 50 | this.accountService.setCurrentUser(user) 51 | } 52 | const updatedMember = {...this.member()} 53 | updatedMember.photoUrl = photo.url; 54 | updatedMember.photos.forEach(p => { 55 | if (p.isMain) p.isMain = false; 56 | if (p.id === photo.id) p.isMain = true; 57 | }); 58 | this.memberChange.emit(updatedMember) 59 | } 60 | }) 61 | } 62 | 63 | initializeUploader() { 64 | this.uploader = new FileUploader({ 65 | url: this.baseUrl + 'users/add-photo', 66 | authToken: 'Bearer ' + this.accountService.currentUser()?.token, 67 | isHTML5: true, 68 | allowedFileType: ['image'], 69 | removeAfterUpload: true, 70 | autoUpload: false, 71 | maxFileSize: 10 * 1024 * 1024, 72 | }); 73 | 74 | this.uploader.onAfterAddingFile = (file) => { 75 | file.withCredentials = false 76 | } 77 | 78 | this.uploader.onSuccessItem = (item, response, status, headers) => { 79 | const photo = JSON.parse(response); 80 | const updatedMember = {...this.member()} 81 | updatedMember.photos.push(photo); 82 | this.memberChange.emit(updatedMember); 83 | if (photo.isMain) { 84 | const user = this.accountService.currentUser(); 85 | if (user) { 86 | user.photoUrl = photo.url; 87 | this.accountService.setCurrentUser(user) 88 | } 89 | updatedMember.photoUrl = photo.url; 90 | updatedMember.photos.forEach(p => { 91 | if (p.isMain) p.isMain = false; 92 | if (p.id === photo.id) p.isMain = true; 93 | }); 94 | this.memberChange.emit(updatedMember) 95 | } 96 | } 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /client/src/app/messages/messages.component.css: -------------------------------------------------------------------------------- 1 | .rounded-circle { 2 | max-height: 50px; 3 | } -------------------------------------------------------------------------------- /client/src/app/messages/messages.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 8 | 12 | 16 |
17 |
18 |
19 | 20 | @if (!messageService.paginatedResult()?.items || messageService.paginatedResult()?.items?.length === 0 ) { 21 |

No messages

22 | } @else { 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | @for (message of messageService.paginatedResult()?.items; track message.id) { 34 | 35 | 36 | 49 | 50 | 51 | 52 | 53 | } 54 | 55 |
MessageFrom / ToSent / Received
{{message.content}} 37 |
38 | image of user 45 | {{isOutbox 46 | ? message.recipientUsername : message.senderUsername}} 47 |
48 |
{{message.messageSent | timeago}}
56 | } 57 | 58 | @if (messageService.paginatedResult()?.pagination && messageService.paginatedResult()?.pagination?.totalItems! > 0) { 59 |
60 | 71 | 72 |
73 | 74 | } 75 | 76 | -------------------------------------------------------------------------------- /client/src/app/messages/messages.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { MessageService } from '../_services/message.service'; 3 | import { ButtonsModule } from 'ngx-bootstrap/buttons'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { TimeagoModule } from 'ngx-timeago'; 6 | import { Message } from '../_models/message'; 7 | import { RouterLink } from '@angular/router'; 8 | import { PaginationModule } from 'ngx-bootstrap/pagination'; 9 | 10 | @Component({ 11 | selector: 'app-messages', 12 | standalone: true, 13 | imports: [ButtonsModule, FormsModule, TimeagoModule, RouterLink, PaginationModule], 14 | templateUrl: './messages.component.html', 15 | styleUrl: './messages.component.css' 16 | }) 17 | export class MessagesComponent implements OnInit { 18 | messageService = inject(MessageService); 19 | container = 'Inbox'; 20 | pageNumber = 1; 21 | pageSize = 5; 22 | isOutbox = this.container === 'Outbox'; 23 | 24 | ngOnInit(): void { 25 | this.loadMessages(); 26 | } 27 | 28 | loadMessages() { 29 | this.messageService.getMessages(this.pageNumber, this.pageSize, this.container); 30 | } 31 | 32 | deleteMessage(id: number) { 33 | this.messageService.deleteMessage(id).subscribe({ 34 | next: _ => { 35 | this.messageService.paginatedResult.update(prev => { 36 | if (prev && prev.items) { 37 | prev.items.splice(prev.items.findIndex(m => m.id === id), 1); 38 | return prev; 39 | } 40 | return prev; 41 | }) 42 | } 43 | }) 44 | } 45 | 46 | getRoute(message: Message) { 47 | if (this.container === 'Outbox') return `/members/${message.recipientUsername}`; 48 | else return `/members/${message.senderUsername}`; 49 | } 50 | 51 | pageChanged(event: any) { 52 | if (this.pageNumber !== event.page) { 53 | this.pageNumber = event.page; 54 | this.loadMessages(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/app/modals/confirm-dialog/confirm-dialog.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/modals/confirm-dialog/confirm-dialog.component.css -------------------------------------------------------------------------------- /client/src/app/modals/confirm-dialog/confirm-dialog.component.html: -------------------------------------------------------------------------------- 1 | 4 | 7 | 11 | -------------------------------------------------------------------------------- /client/src/app/modals/confirm-dialog/confirm-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { BsModalRef } from 'ngx-bootstrap/modal'; 3 | 4 | @Component({ 5 | selector: 'app-confirm-dialog', 6 | standalone: true, 7 | imports: [], 8 | templateUrl: './confirm-dialog.component.html', 9 | styleUrl: './confirm-dialog.component.css' 10 | }) 11 | export class ConfirmDialogComponent { 12 | bsModalRef = inject(BsModalRef); 13 | title = ''; 14 | message = ''; 15 | btnOkText = ''; 16 | btnCancelText = ''; 17 | result = false; 18 | 19 | confirm() { 20 | this.result = true; 21 | this.bsModalRef.hide(); 22 | } 23 | 24 | decline() { 25 | this.bsModalRef.hide(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/app/modals/roles-modal/roles-modal.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/modals/roles-modal/roles-modal.component.css -------------------------------------------------------------------------------- /client/src/app/modals/roles-modal/roles-modal.component.html: -------------------------------------------------------------------------------- 1 | 7 | 22 | 26 | -------------------------------------------------------------------------------- /client/src/app/modals/roles-modal/roles-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { BsModalRef } from 'ngx-bootstrap/modal'; 3 | 4 | @Component({ 5 | selector: 'app-roles-modal', 6 | standalone: true, 7 | imports: [], 8 | templateUrl: './roles-modal.component.html', 9 | styleUrl: './roles-modal.component.css' 10 | }) 11 | export class RolesModalComponent { 12 | bsModalRef = inject(BsModalRef); 13 | username = ''; 14 | title = ''; 15 | availableRoles: string[] = []; 16 | selectedRoles: string[] = []; 17 | rolesUpdated = false; 18 | 19 | updateChecked(checkedValue: string) { 20 | if (this.selectedRoles.includes(checkedValue)) { 21 | this.selectedRoles = this.selectedRoles.filter(r => r !== checkedValue) 22 | } else { 23 | this.selectedRoles.push(checkedValue); 24 | } 25 | } 26 | 27 | onSelectRoles() { 28 | this.rolesUpdated = true; 29 | this.bsModalRef.hide(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app/nav/nav.component.css: -------------------------------------------------------------------------------- 1 | .dropdown-toggle, .dropdown-item { 2 | cursor: pointer; 3 | } 4 | 5 | img { 6 | max-height: 50px; 7 | border: 2px solid white; 8 | display: inline; 9 | } -------------------------------------------------------------------------------- /client/src/app/nav/nav.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/nav/nav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { AccountService } from '../_services/account.service'; 4 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; 5 | import { Router, RouterLink, RouterLinkActive } from '@angular/router'; 6 | import { ToastrService } from 'ngx-toastr'; 7 | import { HasRoleDirective } from '../_directives/has-role.directive'; 8 | 9 | @Component({ 10 | selector: 'app-nav', 11 | standalone: true, 12 | imports: [FormsModule, BsDropdownModule, RouterLink, RouterLinkActive, HasRoleDirective], 13 | templateUrl: './nav.component.html', 14 | styleUrl: './nav.component.css' 15 | }) 16 | export class NavComponent { 17 | accountService = inject(AccountService); 18 | private router = inject(Router) 19 | private toastr = inject(ToastrService); 20 | model: any = {}; 21 | 22 | login() { 23 | this.accountService.login(this.model).subscribe({ 24 | next: _ => { 25 | this.router.navigateByUrl('/members') 26 | }, 27 | error: error => this.toastr.error(error.error) 28 | }) 29 | } 30 | 31 | logout() { 32 | this.accountService.logout(); 33 | this.router.navigateByUrl('/'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/app/register/register.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/app/register/register.component.css -------------------------------------------------------------------------------- /client/src/app/register/register.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Sign up

3 |
4 | 5 |
6 | 7 | 10 | 13 |
14 | 15 | 17 | 18 | 19 | 21 | 22 | 23 | 25 | 26 | 27 | 29 | 30 | 31 | 33 | 34 | 35 | 38 | 39 | 40 | 43 | 44 | 45 | @if (validationErrors) { 46 |
47 |
    48 | @for (error of validationErrors; track $index) { 49 |
  • {{error}}
  • 50 | } 51 |
52 |
53 | } 54 | 55 |
56 | 57 | 58 |
59 |
-------------------------------------------------------------------------------- /client/src/app/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject, output } from '@angular/core'; 2 | import { AbstractControl, FormBuilder, FormGroup, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms'; 3 | import { AccountService } from '../_services/account.service'; 4 | import { NgIf } from '@angular/common'; 5 | import { TextInputComponent } from "../_forms/text-input/text-input.component"; 6 | import { DatePickerComponent } from '../_forms/date-picker/date-picker.component'; 7 | import { Router } from '@angular/router'; 8 | 9 | @Component({ 10 | selector: 'app-register', 11 | standalone: true, 12 | templateUrl: './register.component.html', 13 | styleUrl: './register.component.css', 14 | imports: [ReactiveFormsModule, NgIf, TextInputComponent, DatePickerComponent] 15 | }) 16 | export class RegisterComponent implements OnInit { 17 | private accountService = inject(AccountService); 18 | private fb = inject(FormBuilder); 19 | private router = inject(Router); 20 | cancelRegister = output(); 21 | registerForm: FormGroup = new FormGroup({}); 22 | maxDate = new Date(); 23 | validationErrors: string[] | undefined; 24 | 25 | ngOnInit(): void { 26 | this.initializeForm(); 27 | this.maxDate.setFullYear(this.maxDate.getFullYear() - 18) 28 | } 29 | 30 | initializeForm() { 31 | this.registerForm = this.fb.group({ 32 | gender: ['male'], 33 | username: ['', Validators.required], 34 | knownAs: ['', Validators.required], 35 | dateOfBirth: ['', Validators.required], 36 | city: ['', Validators.required], 37 | country: ['', Validators.required], 38 | password: ['', [Validators.required, Validators.minLength(4), 39 | Validators.maxLength(8)]], 40 | confirmPassword: ['', [Validators.required, this.matchValues('password')]], 41 | }); 42 | this.registerForm.controls['password'].valueChanges.subscribe({ 43 | next: () => this.registerForm.controls['confirmPassword'].updateValueAndValidity() 44 | }) 45 | } 46 | 47 | matchValues(matchTo: string): ValidatorFn { 48 | return (control: AbstractControl) => { 49 | return control.value === control.parent?.get(matchTo)?.value ? null : {isMatching: true} 50 | } 51 | } 52 | 53 | register() { 54 | const dob = this.getDateOnly(this.registerForm.get('dateOfBirth')?.value); 55 | this.registerForm.patchValue({dateOfBirth: dob}); 56 | this.accountService.register(this.registerForm.value).subscribe({ 57 | next: _ => this.router.navigateByUrl('/members') , 58 | error: error => this.validationErrors = error 59 | }) 60 | } 61 | 62 | cancel() { 63 | this.cancelRegister.emit(false); 64 | } 65 | 66 | private getDateOnly(dob: string | undefined) { 67 | if (!dob) return; 68 | return new Date(dob).toISOString().slice(0, 10); 69 | } 70 | } -------------------------------------------------------------------------------- /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/assets/.gitkeep -------------------------------------------------------------------------------- /client/src/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/assets/user.png -------------------------------------------------------------------------------- /client/src/environments/environment.development.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiUrl: 'https://localhost:5001/api/', 4 | hubsUrl: 'https://localhost:5001/hubs/', 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiUrl: 'api/', 4 | hubsUrl: 'hubs/', 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/DatingApp-net8/2eea23da5a17b9c89c2278086221d0a6f0d4ef66/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /client/src/styles.css: -------------------------------------------------------------------------------- 1 | .tab-panel { 2 | border: 1px solid #ddd; 3 | padding: 10px; 4 | border-radius: 4px; 5 | } 6 | 7 | .nav-tabs > li.open, .member-tabset > .nav-tabs > li:hover { 8 | border-bottom: 4px solid #fbcdcf; 9 | } 10 | 11 | .member-tabset > .nav-tabs > li.open > a, .member-tabset > .nav-tabs > li:hover > a { 12 | border: 0 !important; 13 | background: none !important; 14 | color: #333333; 15 | } 16 | 17 | .member-tabset > .nav-tabs > li.open > a > i, .member-tabset > .nav-tabs > li:hover > a > i { 18 | color: #a6a6a6; 19 | } 20 | 21 | .member-tabset > .nav-tabs > li.open .dropdown-menu, .member-tabset > .nav-tabs > li:hover .dropdown-menu { 22 | margin-top: 0px; 23 | } 24 | 25 | .member-tabset > .nav-tabs > li.active { 26 | border-bottom: 4px solid #E95420; 27 | position: relative; 28 | } 29 | 30 | .member-tabset > .nav-tabs > li.active > a { 31 | border: 0 !important; 32 | color: #333333; 33 | } 34 | 35 | .member-tabset > .nav-tabs > li.active > a > i { 36 | color: #404040; 37 | } 38 | 39 | .member-tabset > .tab-content { 40 | margin-top: -3px; 41 | background-color: #fff; 42 | border: 0; 43 | border-top: 1px solid #eee; 44 | padding: 15px 0; 45 | } 46 | 47 | .nav-tabs { 48 | --bs-nav-tabs-border-width: 0px; 49 | } -------------------------------------------------------------------------------- /client/ssl/localhost-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDt2/9cXfG4454s 3 | /FfQouEkWIj4fisBBz8H6YephToIuQPyH5iVI8h4WpS9D9+Y22VcKJGPqZpd5h/Q 4 | OGLIOlt98Q6IdjucHOO0gh3k3bhrYsqOZ8OlzLJqusCL5ndDDd3Qg9paP8TYZem5 5 | VxkuRTiJ9+oElt3zmVT+bIVACpXGW3mOUflP5x0bSRM+wJ6cyruHgOp88PtQGgDE 6 | iekQDhRHhcOuaNBgUinnwNcAymtqj6PYf50Y3WQaCZ+gC3dY310fB8MXl6UVObDF 7 | 2RL48BrggUigx4bLEDGYd4Kao3SpWlvlbjJFo+Lz5jWfEcLYlT7pxdtJ9h6Ff93z 8 | SucOlyBLAgMBAAECggEAekywuyxumjMm5FiHSnZFLuv62VH+CJRSO14+69Hdqhh/ 9 | R+IpER4J+KASdDeSL0U3k7AkT+rTvU4Ss3wahntDCbmFUHMCaV2NUwXIGyJJraVp 10 | ItmFhl1+q1QEpqpETgz2LT1uaxL4wo98Ilj/UIQ08vOuttdfnd4MDpl71hbbNdZb 11 | Ic45hfNSCDWF3gZccjnJ57+S5AGa2Ch15ahRysFLCjtyBPDygIXvVi3xOgR0XQAo 12 | VrIJhqdQaDDKZkw78zJw2Bp5HVUzYnRCkmTM2neBNKh0mU4/2uqR6H0LSK4t9V1b 13 | K/VOyh82JstvvfThY9RO/lN3kR5fbKbHhaU3kS7/0QKBgQD1h4x1ikSco3nVa9zk 14 | jTMHTvJtPNvGDToYgFfpQ82GMbIh8VW532SO5sHao9/VYe6etyKI8YEArNlWwJF8 15 | PO2y3Exm6PR0U4EVHmSZoqlF80uSr4GqTQS6vCPRiH6n5srxu6iB+jga87ZJktHD 16 | efYLP34FEO3TefkhqpRqYoqL0wKBgQD4ALbEJ/7tsA3qyHzEYYSiXRZllLYAi/D+ 17 | Jxyl3NYFsLSUmag1VELqIIG6o/9UtdL1/YpTUYqSH0VmcLSqrs0GHP6dnhuH6dgc 18 | 8chGn85zSYhTz/XwPGrsYzi/FvL+8zPAXYFDasY5C7cUCTyfLVlqgvJkkrWOckp7 19 | P98s7/KmqQKBgQCBaLYhZYUQQiF+2WENnVZd7cBczwzO8D3EmDC9o5z5s8u9lCOo 20 | 2hN4NivKf0EEiJ9qTAAJybBCmNfcn5aOstZdxTsHqpTdkv2gEerYByHM2pTkdViU 21 | WA+8FFmUoKqQ+FXS3yPLjgRwQC+9y4J/0xJZj1dueCPBqLIkinG7OMDpPQKBgQDx 22 | 6vhd5kn2IAujYAjdI+dW3okvc94KMHhX4109qmsXx+SPJEiCJPzVF/qUTs+OGYN9 23 | M+KQHfWXTmvLXtvNt5AFi5kPtaBTd1e5/FyKD+86ZJtYbn8Q5k7C4pMDTGajLifo 24 | WQ3z7p8IHJZtNAlvmLQlgzDhzH7QQkrHaWnRkwrXaQKBgQCwBGjihdWWjr1PHs+X 25 | UqjKNNhDLwTq7JGhf/aC5UOTDlBVWSLxJ1iibx17Oj76amPBpGQrz3+8w0x8gTr0 26 | epVMotlk6HNwNoafBc9L8DwvCW15y+m7FYjt+lmLPXObV/PX/a/cPbYetsnBTg9H 27 | 7ajLEHs5JQyAJXcCo2WezUq2mQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /client/ssl/localhost.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIERDCCAqygAwIBAgIRAPocMRYQkpn00zD2lgL+zKgwDQYJKoZIhvcNAQELBQAw 3 | gYMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEsMCoGA1UECwwjbmVp 4 | bEBOZWlscy1NYWNCb29rLVByby5sb2NhbCAoTmVpbCkxMzAxBgNVBAMMKm1rY2Vy 5 | dCBuZWlsQE5laWxzLU1hY0Jvb2stUHJvLmxvY2FsIChOZWlsKTAeFw0yNDA1MjEw 6 | ODIyMzFaFw0yNjA4MjEwODIyMzFaMFcxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9w 7 | bWVudCBjZXJ0aWZpY2F0ZTEsMCoGA1UECwwjbmVpbEBOZWlscy1NYWNCb29rLVBy 8 | by5sb2NhbCAoTmVpbCkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDt 9 | 2/9cXfG4454s/FfQouEkWIj4fisBBz8H6YephToIuQPyH5iVI8h4WpS9D9+Y22Vc 10 | KJGPqZpd5h/QOGLIOlt98Q6IdjucHOO0gh3k3bhrYsqOZ8OlzLJqusCL5ndDDd3Q 11 | g9paP8TYZem5VxkuRTiJ9+oElt3zmVT+bIVACpXGW3mOUflP5x0bSRM+wJ6cyruH 12 | gOp88PtQGgDEiekQDhRHhcOuaNBgUinnwNcAymtqj6PYf50Y3WQaCZ+gC3dY310f 13 | B8MXl6UVObDF2RL48BrggUigx4bLEDGYd4Kao3SpWlvlbjJFo+Lz5jWfEcLYlT7p 14 | xdtJ9h6Ff93zSucOlyBLAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE 15 | DDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBRyDNZ+UUHjrrvau2sBQm+N43SxVzAU 16 | BgNVHREEDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAMeuvjfXoYqA 17 | hWI8JezuuX/KvLThFPctw6FCWV+3VdBCjQgLqFhkwX6dfBcvlBgklVPm5VEJCTft 18 | 04NGZzkUZcVeayNRpBbiOM9iN5G64VtddAK7c7i9uDN/vPhHJeOhrXvx5RcCD5XK 19 | ra5YGmqkgONCsyM+cm1RGinUWETbxfnV14wVBRNi/v1r6mQ+3FCq2zuafHBHOVcg 20 | 0r3SogGGDAjT3BubLZtuM7JAAmtBYjO9GTuJSmVv8J93mHKitUTA+ET+dO0aQHeC 21 | zyqDjln+Ngu4Ismn+HTJyENxo0WdbRIAfWbxgYEyrYNDtzc209w74MDi58lWKvl1 22 | KITqoQkvb0VsknN1Cm+6XuFhXXKMIwOMVgxktyPA972k3IDNvNeAw1eTs5m9Gf7z 23 | BbnM5aOT2FW9OlBbSywEKVlsC0RrGFLulg/BbXUg2U9fhhBYNj4rTs28uy43losz 24 | 9Hhy7tLMpyoQhlu45sWjMjvAy6N/VYa4mxgCGr7XrBFR5E80Rb1FJw== 25 | -----END CERTIFICATE----- 26 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "useDefineForClassFields": false, 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | sql: 3 | image: mcr.microsoft.com/azure-sql-edge 4 | container_name: sql 5 | environment: 6 | ACCEPT_EULA: "1" 7 | MSSQL_SA_PASSWORD: "Password@1" 8 | ports: 9 | - "1433:1433" --------------------------------------------------------------------------------