diff --git a/.github/workflows/main_dating-app-htec-intern.yml b/.github/workflows/main_dating-app-htec-intern.yml
new file mode 100644
index 0000000..d7c5336
--- /dev/null
+++ b/.github/workflows/main_dating-app-htec-intern.yml
@@ -0,0 +1,82 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy ASP.Net Core app to Azure Web App - Dating-App-HTEC-Intern
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: windows-latest
+ permissions:
+ contents: read #This is required for actions/checkout
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '20'
+
+ - name: Install Angular CLI
+ run: npm install -g @angular/cli@19
+
+ - name: Install deps and build angular app
+ run: |
+ cd client
+ npm install
+ ng build
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.x'
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release
+
+ - name: dotnet publish
+ run: dotnet publish -c Release -o "${{env.DOTNET_ROOT}}/myapp"
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v4
+ with:
+ name: .net-app
+ path: ${{env.DOTNET_ROOT}}/myapp
+
+ deploy:
+ runs-on: windows-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ permissions:
+ id-token: write #This is required for requesting the JWT
+ contents: read #This is required for actions/checkout
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v4
+ with:
+ name: .net-app
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_208CA351617B4FA788EAE781A942B3F9 }}
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_E47467B042204ABB9220C7F466DA4B13 }}
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_288A10112D9A4341B4F6DAD43DAEC34F }}
+
+ - name: Deploy to Azure Web App
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v3
+ with:
+ app-name: 'Dating-App-HTEC-Intern'
+ slot-name: 'Production'
+ package: .
+
\ No newline at end of file
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
new file mode 100644
index 0000000..929a72e
--- /dev/null
+++ b/API.Tests/API.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/API.Tests/PhotoApprovalTests.cs b/API.Tests/PhotoApprovalTests.cs
new file mode 100644
index 0000000..bfd73af
--- /dev/null
+++ b/API.Tests/PhotoApprovalTests.cs
@@ -0,0 +1,208 @@
+using Moq;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Identity;
+using API.Interfaces;
+using API.Entities;
+using API.Services;
+using Xunit;
+using API.Services._Admin;
+using Microsoft.AspNetCore.SignalR;
+using API.SignalR;
+using API.Errors;
+
+namespace API.Tests
+{
+ public class PhotoApprovalTests
+ {
+ private readonly Mock _unitOfWorkMock;
+ private readonly Mock _photoRepoMock;
+ private readonly Mock _userRepoMock;
+ private readonly Mock _photoServiceMock;
+ private readonly Mock> _userManagerMock;
+ private readonly Mock> _hubContext;
+ private readonly AdminService _adminService;
+
+ public PhotoApprovalTests()
+ {
+ _unitOfWorkMock = new Mock();
+ _photoRepoMock = new Mock();
+ _userRepoMock = new Mock();
+ _photoServiceMock = new Mock();
+ _userManagerMock = new Mock>(
+ Mock.Of>(), null, null, null, null, null, null, null, null
+ );
+ _hubContext = new Mock>();
+
+ _unitOfWorkMock.Setup(u => u.PhotoRepository).Returns(_photoRepoMock.Object);
+ _unitOfWorkMock.Setup(u => u.UserRepository).Returns(_userRepoMock.Object);
+
+ _adminService = new AdminService(
+ _userManagerMock.Object,
+ _unitOfWorkMock.Object,
+ _photoServiceMock.Object,
+ _hubContext.Object
+ );
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_ApprovesPhoto_WhenPhotoExists()
+ {
+ // Arrange
+ var appUserId = 2;
+ var photo = CreateTestPhoto(appUserId: appUserId, isMain: false, isApproved: false, id: 1);
+ var user = CreateTestUser(appUserId, photo);
+
+ SetupMocksForSuccessfulApproval(photo, user);
+
+ // Act
+ await _adminService.ApprovePhoto(photo.Id);
+
+ // Assert
+ Assert.True(photo.IsApproved);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Once);
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_SetAsMain_WhenNoMainPhotoExists()
+ {
+ // Arrange
+ var appUserId = 2;
+ var photo = CreateTestPhoto(appUserId: appUserId);
+ var user = CreateTestUser(appUserId, photo);
+
+ SetupMocksForSuccessfulApproval(photo, user);
+
+ // Act
+ await _adminService.ApprovePhoto(photo.Id);
+
+ // Assert
+ Assert.True(photo.IsMain);
+ Assert.True(photo.IsApproved);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Once);
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_DoesNotSetAsMain_WhenMainPhotoAlreadyExists()
+ {
+ // Arrange
+ var appUserId = 2;
+ var mainPhoto = CreateTestPhoto(id: 1, isMain: true, isApproved: true, appUserId: appUserId);
+ var photoToApprove = CreateTestPhoto(id: 2, isMain: false, isApproved: false, appUserId: appUserId);
+ var user = CreateTestUser(appUserId, mainPhoto, photoToApprove);
+
+ SetupMocksForSuccessfulApproval(photoToApprove, user);
+
+ // Act
+ await _adminService.ApprovePhoto(photoToApprove.Id);
+
+ // Assert
+ Assert.False(photoToApprove.IsMain);
+ Assert.True(photoToApprove.IsApproved);
+ Assert.True(mainPhoto.IsMain); // Main photo should remain unchanged
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_ThrowsNotFoundException_WhenPhotoNotFound()
+ {
+ // Arrange
+ const int nonExistentPhotoId = 999;
+ _photoRepoMock.Setup(r => r.GetPhotoById(nonExistentPhotoId))
+ .ReturnsAsync((Photo?)null);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => _adminService.ApprovePhoto(nonExistentPhotoId));
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Never);
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_ThrowsNotFoundException_WhenProblemApproving()
+ {
+ // Arrange
+ var appUserId = 2;
+ var photo = CreateTestPhoto(appUserId: appUserId);
+ var user = CreateTestUser(appUserId, photo);
+
+ _photoRepoMock.Setup(r => r.GetPhotoById(photo.Id)).ReturnsAsync(photo);
+ _photoRepoMock.Setup(r => r.GetUserByPhotoId(photo.Id)).ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.Complete()).ReturnsAsync(false); // Simulate failure
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => _adminService.ApprovePhoto(photo.Id));
+ Assert.Equal("Failed to approve photo", ex.Message);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Once); // Promena: Times.Once umesto Times.Never
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-999)]
+ public async Task ApprovePhotoAsync_ThrowsNotFoundException_WhenPhotoIdIsInvalid(int invalidId)
+ {
+ // Arrange
+ _photoRepoMock.Setup(r => r.GetPhotoById(invalidId))
+ .ReturnsAsync((Photo?)null);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _adminService.ApprovePhoto(invalidId));
+
+ Assert.Equal("Photo not found", exception.Message);
+ }
+
+ [Fact]
+ public async Task ApprovePhotoAsync_ThrowsNotFoundException_WhenUserNotFound()
+ {
+ // Arrange
+ var appUserId = 2;
+ var photo = CreateTestPhoto(appUserId: appUserId);
+
+ _photoRepoMock.Setup(r => r.GetPhotoById(photo.Id)).ReturnsAsync(photo);
+ _photoRepoMock.Setup(r => r.GetUserByPhotoId(photo.Id))
+ .ReturnsAsync((AppUser?)null);
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => _adminService.ApprovePhoto(photo.Id));
+ Assert.Equal("User not found", ex.Message);
+ }
+
+ #region Helper Methods
+
+ private static Photo CreateTestPhoto(int id = 1, bool isMain = false, bool isApproved = false, int appUserId = 2)
+ {
+ return new Photo
+ {
+ Id = id,
+ AppUserId = appUserId,
+ IsApproved = isApproved,
+ IsMain = isMain,
+ Url = $"https://test.com/photo{id}.jpg"
+ };
+ }
+
+ private static AppUser CreateTestUser(int appUserId, params Photo[] photos)
+ {
+ return new AppUser
+ {
+ Id = appUserId,
+ UserName = "testuser",
+ KnownAs = "Test User",
+ Gender = "male",
+ City = "TestCity",
+ Country = "TestCountry",
+ Photos = photos.ToList()
+ };
+ }
+
+ private void SetupMocksForSuccessfulApproval(Photo photo, AppUser user)
+ {
+ _photoRepoMock.Setup(r => r.GetPhotoById(photo.Id)).ReturnsAsync(photo);
+ _photoRepoMock.Setup(r => r.GetUserByPhotoId(photo.Id)).ReturnsAsync(user); // OVO JE KLJUČNO
+ _unitOfWorkMock.Setup(u => u.Complete()).ReturnsAsync(true);
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/API.Tests/PhotoDeleteTests.cs b/API.Tests/PhotoDeleteTests.cs
new file mode 100644
index 0000000..67eeab2
--- /dev/null
+++ b/API.Tests/PhotoDeleteTests.cs
@@ -0,0 +1,276 @@
+using Moq;
+using API.Interfaces;
+using API.Entities;
+using API.Services;
+using Microsoft.Extensions.Logging;
+using AutoMapper;
+using CloudinaryDotNet.Actions;
+using Xunit;
+using API.Services._User;
+using API.Errors;
+namespace API.Tests
+{
+ public class PhotoDeleteTests
+ {
+ private readonly Mock _unitOfWorkMock;
+ private readonly Mock _photoServiceMock;
+ private readonly Mock _mapperMock;
+ private readonly UserService _userService;
+ public PhotoDeleteTests()
+ {
+ _unitOfWorkMock = new Mock();
+ _photoServiceMock = new Mock();
+ _mapperMock = new Mock();
+
+ _userService = new UserService(
+ _unitOfWorkMock.Object,
+ _mapperMock.Object,
+ _photoServiceMock.Object
+ );
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldDeletePhoto_WhenValid()
+ {
+ // Arrange
+ var photo = CreateTestPhoto(id: 1, isMain: false, publicId: "cloud123");
+ var user = CreateTestUser("reuf", photo);
+
+ SetupMocksForSuccessfulDeletion(user, photo);
+
+ // Act
+ await _userService.DeletePhoto(1, "reuf");
+
+ // Assert
+ Assert.False(user.Photos.Contains(photo));
+ VerifyDeletionCalls(photo);
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldDeleteMainPhoto_WhenOtherPhotosExist()
+ {
+ // Arrange
+ var mainPhoto = CreateTestPhoto(id: 1, isMain: true, publicId: "main123");
+ var otherPhoto = CreateTestPhoto(id: 2, isMain: false, publicId: "other123");
+ var user = CreateTestUser("reuf", mainPhoto, otherPhoto);
+
+ // Setup different behavior - test should expect BadRequestException
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(mainPhoto);
+
+ // Act & Assert - expecting BadRequestException since deleting main photo is not allowed
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(1, "reuf"));
+
+ Assert.Equal("You cannot delete your main photo", exception.Message);
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowException_WhenPhotoDoesNotBelongToUser()
+ {
+ // Arrange
+ var photo = CreateTestPhoto(id: 1, appUserId: 999); // Different user ID
+ var user = CreateTestUser("reuf"); // User ID will be 2 by default
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(photo);
+
+ // Act & Assert - This should throw some kind of exception when photo doesn't belong to user
+ var exception = await Assert.ThrowsAnyAsync(
+ () => _userService.DeletePhoto(1, "reuf"));
+
+ // Verify that no photo service calls were made since authorization should fail
+ VerifyNoPhotoServiceCalls();
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowBadRequestException_WhenDeletingOnlyMainPhoto()
+ {
+ // Arrange
+ var mainPhoto = CreateTestPhoto(id: 1, isMain: true);
+ var user = CreateTestUser("reuf", mainPhoto);
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(mainPhoto);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(1, "reuf"));
+
+ Assert.Equal("You cannot delete your main photo", exception.Message);
+ VerifyNoPhotoServiceCalls();
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowException_WhenSaveFails()
+ {
+ // Arrange
+ var photo = CreateTestPhoto(id: 1, isMain: false, publicId: "cloud123");
+ var user = CreateTestUser("testuser", photo);
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("testuser"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(photo);
+ _photoServiceMock.Setup(p => p.DeletePhotoAsync("cloud123"))
+ .ReturnsAsync(new DeletionResult { Result = "ok" });
+ _unitOfWorkMock.Setup(u => u.Complete()).ReturnsAsync(false); // Simulate save failure
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(1, "testuser"));
+
+ Assert.Equal("Failed to delete the photo", exception.Message); // Updated message
+ _photoServiceMock.Verify(p => p.DeletePhotoAsync("cloud123"), Times.Once);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Once);
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowNotFoundException_WhenUserNotFound()
+ {
+ // Arrange
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("nonexistent"))
+ .ReturnsAsync((AppUser?)null);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(1, "nonexistent"));
+
+ Assert.Equal("User not found", exception.Message);
+ VerifyNoPhotoServiceCalls();
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowNotFoundException_WhenPhotoNotFound()
+ {
+ // Arrange
+ var user = CreateTestUser("reuf");
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(999))
+ .ReturnsAsync((Photo?)null);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(999, "reuf"));
+
+ Assert.Equal("Photo not found", exception.Message);
+ VerifyNoPhotoServiceCalls();
+ }
+
+ [Theory]
+ [InlineData("ok")]
+ [InlineData("success")]
+ public async Task DeletePhotoAsync_ShouldSucceed_WithDifferentCloudinaryResults(string result)
+ {
+ // Arrange
+ var photo = CreateTestPhoto(id: 1, isMain: false, publicId: "cloud123");
+ var user = CreateTestUser("reuf", photo);
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(photo);
+ _photoServiceMock.Setup(p => p.DeletePhotoAsync("cloud123"))
+ .ReturnsAsync(new DeletionResult { Result = result });
+ _unitOfWorkMock.Setup(u => u.Complete()).ReturnsAsync(true);
+
+ // Act
+ await _userService.DeletePhoto(1, "reuf");
+
+ // Assert
+ Assert.False(user.Photos.Contains(photo));
+ VerifyDeletionCalls(photo);
+ }
+
+ [Fact]
+ public async Task DeletePhotoAsync_ShouldThrowBadRequestException_WhenCloudinaryDeletionFails()
+ {
+ // Arrange
+ var photo = CreateTestPhoto(id: 1, isMain: false, publicId: "cloud123");
+ var user = CreateTestUser("reuf", photo);
+
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync("reuf"))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(1))
+ .ReturnsAsync(photo);
+ _photoServiceMock.Setup(p => p.DeletePhotoAsync("cloud123"))
+ .ReturnsAsync(new DeletionResult { Result = "error", Error = new Error { Message = "Failed to delete" } });
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ () => _userService.DeletePhoto(1, "reuf"));
+
+ Assert.Equal("Failed to delete", exception.Message);
+ _photoServiceMock.Verify(p => p.DeletePhotoAsync("cloud123"), Times.Once);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Never);
+ }
+
+ #region Helper Methods
+
+ private static Photo CreateTestPhoto(int id = 1, bool isMain = false, int appUserId = 2, string? publicId = null)
+ {
+ return new Photo
+ {
+ Id = id,
+ Url = $"http://test{id}.com",
+ PublicId = publicId ?? $"cloud{id}",
+ IsMain = isMain,
+ AppUserId = appUserId
+ };
+ }
+
+ private static AppUser CreateTestUser(string username, params Photo[] photos)
+ {
+ var userId = photos.FirstOrDefault()?.AppUserId ?? 2;
+ return new AppUser
+ {
+ Id = userId,
+ UserName = username,
+ KnownAs = username.Substring(0, 1).ToUpper() + username.Substring(1),
+ Gender = "male",
+ City = "TestCity",
+ Country = "TestCountry",
+ Photos = photos.ToList()
+ };
+ }
+
+ private void SetupMocksForSuccessfulDeletion(AppUser user, Photo photo)
+ {
+ _unitOfWorkMock.Setup(u => u.UserRepository.GetUserByUsernameAsync(user.UserName))
+ .ReturnsAsync(user);
+ _unitOfWorkMock.Setup(u => u.PhotoRepository.GetPhotoById(photo.Id))
+ .ReturnsAsync(photo);
+ _photoServiceMock.Setup(p => p.DeletePhotoAsync(photo.PublicId))
+ .ReturnsAsync(new DeletionResult { Result = "ok" });
+ _unitOfWorkMock.Setup(u => u.Complete()).ReturnsAsync(true);
+ }
+
+ private void VerifyDeletionCalls(Photo photo)
+ {
+ _photoServiceMock.Verify(p => p.DeletePhotoAsync(photo.PublicId), Times.Once);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Once);
+ }
+
+ private void VerifyNoPhotoServiceCalls()
+ {
+ _photoServiceMock.Verify(p => p.DeletePhotoAsync(It.IsAny()), Times.Never);
+ _unitOfWorkMock.Verify(u => u.Complete(), Times.Never);
+ }
+
+ private void VerifyNoMockCalls()
+ {
+ _unitOfWorkMock.VerifyNoOtherCalls();
+ _photoServiceMock.VerifyNoOtherCalls();
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/API/API.csproj b/API/API.csproj
index 852855e..b057948 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -9,12 +9,23 @@
+
+
+
+
+
+
+
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 071fa11..3db25f5 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -12,11 +12,12 @@ namespace API.Controllers;
public class AccountController(UserManager userManager, ITokenService tokenService, IMapper mapper, ILogger logger) : BaseApiController
{
- private readonly AccountService _accountHelper = new AccountService(userManager, tokenService, mapper);
+ private readonly IAccountService _accountHelper = new AccountService(userManager, tokenService, mapper);
private readonly ILogger _logger = logger;
///
/// POST: /api/account/register
+ /// Registers a new user with the provided registration details.
///
///
///
@@ -45,6 +46,7 @@ public class AccountController(UserManager userManager, ITokenService t
///
/// POST: /api/account/login
+ /// Logs in a user with the provided login credentials and returns user details along with a token.
///
///
///
diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs
index 6f36299..7199c41 100644
--- a/API/Controllers/AdminController.cs
+++ b/API/Controllers/AdminController.cs
@@ -6,17 +6,20 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using API.SignalR;
+using API.DTOs;
using Serilog;
+using System.Security.Claims;
namespace API.Controllers;
public class AdminController(UserManager userManager, IUnitOfWork unitOfWork, IPhotoService photoService, IHubContext hubContext, ILogger logger) : BaseApiController
{
- private readonly AdminService _adminHelper = new AdminService(userManager, unitOfWork, photoService, hubContext);
+ private readonly IAdminService _adminHelper = new AdminService(userManager, unitOfWork, photoService, hubContext);
private readonly ILogger _logger = logger;
///
/// GET /api/admin/users-with-roles
+ /// Retrieves a list of users along with their roles.
///
///
///[AllowAnonymous]
@@ -45,6 +48,7 @@ public async Task GetUsersWithRoles()
///
/// POST /api/admin/edit-roles/{username}
+ /// Edits the roles of a user specified by username.
///
///
///
@@ -75,6 +79,7 @@ public async Task EditRoles(string username, string roles)
///
/// GET /api/admin/photos-to-moderate
+ /// Retrieves a list of photos that need moderation.
///
///
[HttpGet("photos-to-moderate")]
@@ -103,6 +108,7 @@ public async Task GetPhotosForModeration()
///
/// POST /api/admin/approve-photo/{photoId}
+ /// Approves a photo for publication by its ID.
///
///
/// lorem ipsum
@@ -134,6 +140,7 @@ public async Task ApprovePhoto(int photoId)
///
/// POST /api/admin/reject-photo/{photoId}
+ /// Rejects a photo by its ID, removing it from moderation queue and deleting the photo.
///
///
///
@@ -160,4 +167,154 @@ public async Task RejectPhoto(int photoId)
throw;
}
}
-}
\ No newline at end of file
+
+ ///
+ /// POST /api/admin/create-tag
+ /// Creates a new tag with the specified name.
+ ///
+ ///
+ ///
+ ///
+ /// [Authorize(Policy = "ModeratePhotoRole")]
+ [HttpPost("create-tag")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task CreateTag([FromBody] TagCreateDto tagDto)
+ {
+ try
+ {
+ _logger.LogDebug($"AdminController - {nameof(CreateTag)} invoked. (tagDto: {tagDto})");
+ var createdTag = await _adminHelper.CreateTagAsync(tagDto?.Name?.Trim() ?? throw new ArgumentException("Tag name cannot be null or empty."));
+ return Ok(createdTag);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in AdminController.CreateTag");
+ throw;
+ }
+ }
+
+ ///
+ /// GET /api/admin/get-tags
+ /// Retrieves a list of all tags.
+ ///
+ ///
+ /// [Authorize(Policy = "ModeratePhotoRole")]
+ [HttpGet("get-tags")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task GetTags()
+ {
+ try
+ {
+ _logger.LogDebug($"AdminController - {nameof(GetTags)} invoked.");
+ var tags = await _adminHelper.GetTagsAsync();
+ return Ok(tags);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in AdminController.GetTags");
+ throw;
+ }
+ }
+
+ ///
+ /// DELETE /api/admin/delete-tag/{name}
+ /// Deletes a tag by its name.
+ ///
+ ///
+ ///
+ ///
+ /// [Authorize(Policy = "ModeratePhotoRole")]
+ [HttpDelete("delete-tag/{name}")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task DeleteTag(string name)
+ {
+ try
+ {
+ _logger.LogDebug($"AdminController - {nameof(DeleteTag)} invoked. (name: {name})");
+ await _adminHelper.RemoveTagByNameAsync(name?.Trim() ?? throw new ArgumentException("Tag name cannot be null or empty."));
+ return Ok(new { message = "Tag successfully removed." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in AdminController.DeleteTag");
+ throw;
+ }
+ }
+
+ ///
+ /// GET /api/admin/users-without-main-photo
+ /// Retrieves a list of usernames of users who do not have a main photo.
+ ///
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpGet("users-without-main-photo")]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task>> GetUsersWithoutMainPhoto()
+ {
+ try
+ {
+ _logger.LogDebug($"AdminController - {nameof(GetUsersWithoutMainPhoto)} invoked.");
+ var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? User.FindFirst("nameid")?.Value
+ ?? throw new Exception("Cannot get user id from token!"));
+ var users = await _adminHelper.GetUsersWithoutMainPhoto(userId) ?? throw new KeyNotFoundException("No users without main photo found.");
+ return Ok(users);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in AdminController.GetUsersWithoutMainPhoto");
+ throw;
+ }
+ }
+
+ ///
+ /// GET /api/admin/photo-stats
+ /// Retrieves statistics about photos, including approved and unapproved counts for each user.
+ ///
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpGet("photo-stats")]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task>> GetPhotoStats()
+ {
+ try
+ {
+ _logger.LogDebug($"AdminController - {nameof(GetPhotoStats)} invoked.");
+ var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? User.FindFirst("nameId")?.Value
+ ?? throw new Exception("Cannot get user id from token!"));
+ var photoStats = await _adminHelper.GetPhotoApprovalStatisticsAsync(userId) ?? throw new KeyNotFoundException("No photo stats found.");
+ return Ok(photoStats);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in AdminController.GetPhotoStats");
+ throw;
+ }
+ }
+
+}
+
diff --git a/API/Controllers/LikesController.cs b/API/Controllers/LikesController.cs
index 2340cfc..85d1c14 100644
--- a/API/Controllers/LikesController.cs
+++ b/API/Controllers/LikesController.cs
@@ -13,11 +13,12 @@ namespace API.Controllers;
public class LikesController(IUnitOfWork unitOfWork, ILogger logger) : BaseApiController
{
- private readonly LikesService _likesService = new LikesService(unitOfWork);
+ private readonly ILikesService _likesService = new LikesService(unitOfWork);
private readonly ILogger _logger = logger;
///
/// POST /api/likes/{targetUserId:int}
+ /// Toggles a like for the specified user.
///
///
///
@@ -47,6 +48,7 @@ public async Task ToggleLike(int targetUserId)
///
/// GET /api/likes/list
+ /// Retrieves a list of IDs of users that the current user has liked.
///
///
/// [AllowAnonymous]
@@ -75,6 +77,7 @@ public async Task>> GetCurrentUserLikeIds()
///
/// GET /api/likes?predicate={likesParams}
+ /// Retrieves a list of users that the current user has liked or who have liked the current user based on the provided LikesParams.
///
///
///
diff --git a/API/Controllers/MessagesController.cs b/API/Controllers/MessagesController.cs
index f05c769..a852891 100644
--- a/API/Controllers/MessagesController.cs
+++ b/API/Controllers/MessagesController.cs
@@ -13,11 +13,12 @@ namespace API.Controllers;
[Authorize]
public class MessagesController(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger) : BaseApiController
{
- private readonly MessageService _messageHelper = new MessageService(unitOfWork, mapper);
+ private readonly IMessageService _messageHelper = new MessageService(unitOfWork, mapper);
private readonly ILogger _logger = logger;
///
/// POST /api/messages
+ /// Creates a new message.
///
///
///
@@ -47,6 +48,7 @@ public async Task CreateMessage(CreateMessageDto createMessageDto
///
/// GET /api/messages?container={messageParams}
+ /// Retrieves messages for the current user based on the specified parameters.
///
///
///
@@ -77,6 +79,7 @@ public async Task>> GetMessagesForUser([Fro
///
/// GET /api/messages/thread/{username}
+ /// Retrieves the message thread between the current user and the specified username.
///
///
///
@@ -105,6 +108,7 @@ public async Task>> GetMessageThread(string
///
/// DELETE /api/messages/{id}
+ /// Deletes a message by its ID for the current user.
///
///
///
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index 9b2dd31..90be3fe 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -13,11 +13,12 @@ namespace API.Controllers;
[Authorize]
public class UsersController(IUnitOfWork unitOfWork, IMapper mapper, IPhotoService photoService, ILogger logger) : BaseApiController
{
- private readonly UserService _userHelper = new UserService(unitOfWork, mapper, photoService);
+ private readonly IUserService _userHelper = new UserService(unitOfWork, mapper, photoService);
private readonly ILogger _logger = logger;
///
/// GET /api/users?predicate={userParams}
+ /// Retrieves a list of users based on the provided user parameters.
///
///
///
@@ -47,6 +48,7 @@ public async Task>> GetUsers([FromQuery] Use
///
/// GET /api/users/{username}
+ /// Retrieves a user by their username.
///
///
///
@@ -75,6 +77,7 @@ public async Task> GetUser(string username)
///
/// PUT /api/users
+ /// Updates the current user's profile information.
///
///
///
@@ -103,6 +106,7 @@ public async Task UpdateUser(MemberUpdateDto memberUpdateDto)
///
/// POST /api/users/add-photo
+ /// Adds a new photo for the user.
///
///
///
@@ -114,12 +118,12 @@ public async Task UpdateUser(MemberUpdateDto memberUpdateDto)
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesErrorResponseType(typeof(void))]
- public async Task> AddPhoto(IFormFile file)
+ public async Task> AddPhoto([FromForm] AddPhotoDto addPhotoDto)
{
try
{
- _logger.LogDebug($"UsersController - {nameof(AddPhoto)} invoked. (file: {file})");
- var photo = await _userHelper.AddPhoto(file, User.GetUsername());
+ _logger.LogDebug($"UsersController - {nameof(AddPhoto)} invoked. (file: {addPhotoDto})");
+ var photo = await _userHelper.AddPhoto(addPhotoDto, User.GetUsername());
return CreatedAtAction(nameof(GetUser), new { username = User.GetUsername() }, photo);
}
catch (Exception ex)
@@ -131,6 +135,7 @@ public async Task> AddPhoto(IFormFile file)
///
/// PUT /api/users/set-main-photo/{photoId:int}
+ /// Sets a user's main photo.
///
///
///
@@ -159,6 +164,7 @@ public async Task SetMainPhoto(int photoId)
///
/// DELETE /api/users/delete-photo/{photoId:int}
+ /// Deletes a user's photo by its ID.
///
///
///
@@ -184,4 +190,88 @@ public async Task DeletePhoto(int photoId)
throw;
}
}
+
+ ///
+ /// POST /api/users/assign-tags/{photoId:int}
+ /// Assigns tags to a photo.
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("assign-tags/{photoId:int}")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task AssignTags(int photoId, [FromBody] List tags)
+ {
+ try
+ {
+ _logger.LogDebug($"UsersController - {nameof(AssignTags)} invoked. (photoId: {photoId}, tags: {string.Join(", ", tags)})");
+ await _userHelper.AssignTags(photoId, User.GetUsername(), tags);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in UsersController.AssignTags");
+ throw;
+ }
+ }
+
+ ///
+ /// GET /api/users/tags
+ /// Retrieves all tags associated with photos.
+ ///
+ ///
+ [HttpGet("tags")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task>> GetTags()
+ {
+ try
+ {
+ _logger.LogDebug($"UsersController - {nameof(GetTags)} invoked.");
+ var tags = await _userHelper.GetTags();
+ return Ok(tags);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in UsersController.GetTags");
+ throw;
+ }
+ }
+
+ ///
+ /// GET /api/users/tags
+ /// Retrieves all tags with photos.
+ ///
+ ///
+ [HttpGet("photos-tags")]
+ [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesErrorResponseType(typeof(void))]
+ public async Task>> GetPhotosWithTagsByUsername()
+ {
+ try
+ {
+ _logger.LogDebug($"UsersController - {nameof(GetPhotosWithTagsByUsername)} invoked.");
+ var photos = await _userHelper.GetPhotoWithTagsByUsernameAsync(User.GetUsername());
+ return Ok(photos);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception in UsersController.GetPhotosWithTagsByUsername");
+ throw;
+ }
+ }
+
}
\ No newline at end of file
diff --git a/API/DTOs/AddPhotoDto.cs b/API/DTOs/AddPhotoDto.cs
new file mode 100644
index 0000000..1a23fe3
--- /dev/null
+++ b/API/DTOs/AddPhotoDto.cs
@@ -0,0 +1,7 @@
+namespace API.DTOs;
+
+public class AddPhotoDto
+{
+ public IFormFile File { get; set; } = null!;
+ public List TagIds { get; set; } = [];
+}
\ No newline at end of file
diff --git a/API/DTOs/AddTagDto.cs b/API/DTOs/AddTagDto.cs
new file mode 100644
index 0000000..b7a4c61
--- /dev/null
+++ b/API/DTOs/AddTagDto.cs
@@ -0,0 +1,6 @@
+namespace API.DTOs;
+
+public class AddTagDto
+{
+ public required string Name { get; set; }
+}
\ No newline at end of file
diff --git a/API/DTOs/PhotoApprovalStatisticsDto.cs b/API/DTOs/PhotoApprovalStatisticsDto.cs
new file mode 100644
index 0000000..c160506
--- /dev/null
+++ b/API/DTOs/PhotoApprovalStatisticsDto.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace API.DTOs;
+
+public class PhotoApprovalStatisticsDto
+{
+ public string? Username { get; set; }
+ public int ApprovedPhotos { get; set; }
+ public int UnapprovedPhotos { get; set; }
+}
diff --git a/API/DTOs/PhotoDto.cs b/API/DTOs/PhotoDto.cs
index 7b11ded..b6201e3 100644
--- a/API/DTOs/PhotoDto.cs
+++ b/API/DTOs/PhotoDto.cs
@@ -5,6 +5,6 @@ public class PhotoDto
public int Id { get; set; }
public string? Url { get; set; }
public bool IsMain { get; set; }
- public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsApproved { get; set; }
+ public List Tags { get; set; } = new List();
}
\ No newline at end of file
diff --git a/API/DTOs/PhotoForApprovalDto.cs b/API/DTOs/PhotoForApprovalDto.cs
index a48c060..818e3c2 100644
--- a/API/DTOs/PhotoForApprovalDto.cs
+++ b/API/DTOs/PhotoForApprovalDto.cs
@@ -4,9 +4,9 @@ namespace API.DTOs;
public class PhotoForApprovalDto
{
- public int Id { get; set; }
+ public int Id { get; set; }
public required string Url { get; set; }
public string? Username { get; set; }
public bool IsApproved { get; set; }
-
+ public List Tags { get; set; } = new List();
}
diff --git a/API/DTOs/TagCreateDto.cs b/API/DTOs/TagCreateDto.cs
new file mode 100644
index 0000000..a0344b2
--- /dev/null
+++ b/API/DTOs/TagCreateDto.cs
@@ -0,0 +1,5 @@
+namespace API.DTOs;
+public class TagCreateDto
+{
+ public required string Name { get; set; }
+}
diff --git a/API/DTOs/TagDto.cs b/API/DTOs/TagDto.cs
new file mode 100644
index 0000000..1355ac1
--- /dev/null
+++ b/API/DTOs/TagDto.cs
@@ -0,0 +1,7 @@
+namespace API.DTOs;
+
+public class TagDto
+{
+ public int Id { get; set; }
+ public required string Name { get; set; }
+}
\ No newline at end of file
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index 1cad4f8..fb3d591 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -1,4 +1,4 @@
-using System;
+using API.DTOs;
using API.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
@@ -10,14 +10,17 @@
namespace API.Data;
public class DataContext(DbContextOptions options) : IdentityDbContext
- , AppUserRole, IdentityUserLogin,
- IdentityRoleClaim, IdentityUserToken>(options)
+ , AppUserRole,
+ IdentityUserLogin, IdentityRoleClaim,
+ IdentityUserToken>(options)
{
public DbSet Likes { get; set; }
public DbSet Messages { get; set; }
public DbSet Groups { get; set; }
public DbSet Connections { get; set; }
public DbSet Photos { get; set; }
+ public DbSet Tags { get; set; }
+ public DbSet PhotoTags { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@@ -65,6 +68,23 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.Entity()
.HasQueryFilter(photo => photo.IsApproved);
+ builder.Entity()
+ .HasKey(pt => new { pt.PhotoId, pt.TagId });
+
+ builder.Entity()
+ .HasOne(pt => pt.Photo)
+ .WithMany(p => p.PhotoTags)
+ .HasForeignKey(pt => pt.PhotoId);
+
+ builder.Entity()
+ .HasOne(pt => pt.Tag)
+ .WithMany(t => t.PhotoTags)
+ .HasForeignKey(pt => pt.TagId);
+
+ builder.Entity()
+ .HasIndex(t => t.Name)
+ .IsUnique();
+
builder.ApplyUtcDateTimeConverter();
}
}
diff --git a/API/Data/Migrations/20250518164536_SqlInitial.Designer.cs b/API/Data/Migrations/20250527175823_InitialCreate.Designer.cs
similarity index 89%
rename from API/Data/Migrations/20250518164536_SqlInitial.Designer.cs
rename to API/Data/Migrations/20250527175823_InitialCreate.Designer.cs
index 9a0c4c4..c2b7094 100644
--- a/API/Data/Migrations/20250518164536_SqlInitial.Designer.cs
+++ b/API/Data/Migrations/20250527175823_InitialCreate.Designer.cs
@@ -12,8 +12,8 @@
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
- [Migration("20250518164536_SqlInitial")]
- partial class SqlInitial
+ [Migration("20250527175823_InitialCreate")]
+ partial class InitialCreate
{
///
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -279,6 +279,41 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("Photos");
});
+ modelBuilder.Entity("API.Entities.PhotoTag", b =>
+ {
+ b.Property("PhotoId")
+ .HasColumnType("int");
+
+ b.Property("TagId")
+ .HasColumnType("int");
+
+ b.HasKey("PhotoId", "TagId");
+
+ b.HasIndex("TagId");
+
+ b.ToTable("PhotoTags");
+ });
+
+ modelBuilder.Entity("API.Entities.Tag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Tags");
+ });
+
modelBuilder.Entity("API.Entities.UserLike", b =>
{
b.Property("SourceUserId")
@@ -439,6 +474,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Navigation("AppUser");
});
+ modelBuilder.Entity("API.Entities.PhotoTag", b =>
+ {
+ b.HasOne("API.Entities.Photo", "Photo")
+ .WithMany("PhotoTags")
+ .HasForeignKey("PhotoId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("API.Entities.Tag", "Tag")
+ .WithMany("PhotoTags")
+ .HasForeignKey("TagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Photo");
+
+ b.Navigation("Tag");
+ });
+
modelBuilder.Entity("API.Entities.UserLike", b =>
{
b.HasOne("API.Entities.AppUser", "LikedUser")
@@ -518,6 +572,16 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
b.Navigation("Connections");
});
+
+ modelBuilder.Entity("API.Entities.Photo", b =>
+ {
+ b.Navigation("PhotoTags");
+ });
+
+ modelBuilder.Entity("API.Entities.Tag", b =>
+ {
+ b.Navigation("PhotoTags");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/API/Data/Migrations/20250518164536_SqlInitial.cs b/API/Data/Migrations/20250527175823_InitialCreate.cs
similarity index 89%
rename from API/Data/Migrations/20250518164536_SqlInitial.cs
rename to API/Data/Migrations/20250527175823_InitialCreate.cs
index fe77591..3fe5d2c 100644
--- a/API/Data/Migrations/20250518164536_SqlInitial.cs
+++ b/API/Data/Migrations/20250527175823_InitialCreate.cs
@@ -6,7 +6,7 @@
namespace API.Data.Migrations
{
///
- public partial class SqlInitial : Migration
+ public partial class InitialCreate : Migration
{
///
protected override void Up(MigrationBuilder migrationBuilder)
@@ -73,6 +73,19 @@ protected override void Up(MigrationBuilder migrationBuilder)
table.PrimaryKey("PK_Groups", x => x.Name);
});
+ migrationBuilder.CreateTable(
+ name: "Tags",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(450)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Tags", x => x.Id);
+ });
+
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
@@ -277,6 +290,30 @@ protected override void Up(MigrationBuilder migrationBuilder)
onDelete: ReferentialAction.Cascade);
});
+ migrationBuilder.CreateTable(
+ name: "PhotoTags",
+ columns: table => new
+ {
+ PhotoId = table.Column(type: "int", nullable: false),
+ TagId = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_PhotoTags", x => new { x.PhotoId, x.TagId });
+ table.ForeignKey(
+ name: "FK_PhotoTags_Photos_PhotoId",
+ column: x => x.PhotoId,
+ principalTable: "Photos",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_PhotoTags_Tags_TagId",
+ column: x => x.TagId,
+ principalTable: "Tags",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
@@ -340,6 +377,17 @@ protected override void Up(MigrationBuilder migrationBuilder)
name: "IX_Photos_AppUserId",
table: "Photos",
column: "AppUserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PhotoTags_TagId",
+ table: "PhotoTags",
+ column: "TagId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Tags_Name",
+ table: "Tags",
+ column: "Name",
+ unique: true);
}
///
@@ -370,7 +418,7 @@ protected override void Down(MigrationBuilder migrationBuilder)
name: "Messages");
migrationBuilder.DropTable(
- name: "Photos");
+ name: "PhotoTags");
migrationBuilder.DropTable(
name: "AspNetRoles");
@@ -378,6 +426,12 @@ protected override void Down(MigrationBuilder migrationBuilder)
migrationBuilder.DropTable(
name: "Groups");
+ migrationBuilder.DropTable(
+ name: "Photos");
+
+ migrationBuilder.DropTable(
+ name: "Tags");
+
migrationBuilder.DropTable(
name: "AspNetUsers");
}
diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs
index 78d6d68..e162c60 100644
--- a/API/Data/Migrations/DataContextModelSnapshot.cs
+++ b/API/Data/Migrations/DataContextModelSnapshot.cs
@@ -276,6 +276,41 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Photos");
});
+ modelBuilder.Entity("API.Entities.PhotoTag", b =>
+ {
+ b.Property("PhotoId")
+ .HasColumnType("int");
+
+ b.Property("TagId")
+ .HasColumnType("int");
+
+ b.HasKey("PhotoId", "TagId");
+
+ b.HasIndex("TagId");
+
+ b.ToTable("PhotoTags");
+ });
+
+ modelBuilder.Entity("API.Entities.Tag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Tags");
+ });
+
modelBuilder.Entity("API.Entities.UserLike", b =>
{
b.Property("SourceUserId")
@@ -436,6 +471,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("AppUser");
});
+ modelBuilder.Entity("API.Entities.PhotoTag", b =>
+ {
+ b.HasOne("API.Entities.Photo", "Photo")
+ .WithMany("PhotoTags")
+ .HasForeignKey("PhotoId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("API.Entities.Tag", "Tag")
+ .WithMany("PhotoTags")
+ .HasForeignKey("TagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Photo");
+
+ b.Navigation("Tag");
+ });
+
modelBuilder.Entity("API.Entities.UserLike", b =>
{
b.HasOne("API.Entities.AppUser", "LikedUser")
@@ -515,6 +569,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
b.Navigation("Connections");
});
+
+ modelBuilder.Entity("API.Entities.Photo", b =>
+ {
+ b.Navigation("PhotoTags");
+ });
+
+ modelBuilder.Entity("API.Entities.Tag", b =>
+ {
+ b.Navigation("PhotoTags");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/API/Data/PhotoRepository.cs b/API/Data/PhotoRepository.cs
index c68c460..d01e97f 100644
--- a/API/Data/PhotoRepository.cs
+++ b/API/Data/PhotoRepository.cs
@@ -1,12 +1,17 @@
using System;
+using System.Data;
+
using API.DTOs;
using API.Entities;
using API.Interfaces;
+using AutoMapper;
+using AutoMapper.QueryableExtensions;
+using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace API.Data;
-public class PhotoRepository(DataContext context) : IPhotoRepository
+public class PhotoRepository(DataContext context, IMapper mapper) : IPhotoRepository
{
public async Task GetPhotoById(int id)
{
@@ -14,21 +19,32 @@ public class PhotoRepository(DataContext context) : IPhotoRepository
.IgnoreQueryFilters()
.SingleOrDefaultAsync(x => x.Id == id);
}
-
- public async Task> GetUnapprovedPhotos()
+ public async Task GetPhotoWithTagsById(int id)
+ {
+ return await context.Photos
+ .Include(p => p.PhotoTags)
+ .ThenInclude(pt => pt.Tag)
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(x => x.Id == id);
+ }
+ public async Task> GetPhotosByUsername(string username)
{
return await context.Photos
+ .Where(p => p.AppUser.UserName == username)
+ .Include(p => p.PhotoTags)
+ .ThenInclude(pt => pt.Tag)
+ .IgnoreQueryFilters()
+ .ToListAsync();
+ }
+ public async Task> GetUnapprovedPhotos()
+ {
+ var query = context.Photos
.IgnoreQueryFilters()
.Where(p => p.IsApproved == false)
- .Select(u => new PhotoForApprovalDto
- {
- Id = u.Id,
- Username = u.AppUser.UserName,
- Url = u.Url,
- IsApproved = u.IsApproved
- }).ToListAsync();
+ .AsQueryable();
+ return await query.ProjectTo(mapper.ConfigurationProvider)
+ .ToListAsync();
}
-
public async Task GetUserByPhotoId(int photoId)
{
return await context.Users
@@ -37,9 +53,69 @@ public async Task> GetUnapprovedPhotos()
.Where(p => p.Photos.Any(p => p.Id == photoId))
.FirstOrDefaultAsync();
}
-
public void RemovePhoto(Photo photo)
{
context.Photos.Remove(photo);
}
+ public void AddTag(Tag tag)
+ {
+ context.Tags.Add(tag);
+ }
+ public async Task> GetTagsAsStrings()
+ {
+ return await context.Tags.Select(t => t.Name).ToListAsync();
+ }
+ public async Task> GetTags()
+ {
+ var query = context.Tags.AsQueryable();
+ return await query.ProjectTo(mapper.ConfigurationProvider).ToListAsync();
+ }
+ public async Task> GetUsersWithoutMainPhotoAsync(int currentUserId)
+ {
+ var result = new List();
+
+ using var command = context.Database.GetDbConnection().CreateCommand();
+ command.CommandText = "GetUsersWithoutMainPhoto";
+ command.CommandType = CommandType.StoredProcedure;
+
+ command.Parameters.Add(new SqlParameter("@CurrentUserId", currentUserId));
+
+ await context.Database.OpenConnectionAsync();
+
+ using var reader = await command.ExecuteReaderAsync();
+
+ while (await reader.ReadAsync())
+ {
+ result.Add(reader.GetString(0));
+ }
+ return result;
+ }
+ public async Task> GetPhotoStatsApprovalAsync(int currentUserId)
+ {
+ var result = new List();
+
+ using var command = context.Database.GetDbConnection().CreateCommand();
+
+ command.CommandText = "GetPhotoStatsApproval";
+ command.CommandType = CommandType.StoredProcedure;
+
+ var userIdParam = new SqlParameter("@CurrentUserId", currentUserId);
+ command.Parameters.Add(userIdParam);
+
+ await context.Database.OpenConnectionAsync();
+
+ using var reader = await command.ExecuteReaderAsync();
+
+ while (await reader.ReadAsync())
+ {
+ result.Add(new PhotoApprovalStatisticsDto
+ {
+ Username = reader.GetString(0),
+ ApprovedPhotos = reader.IsDBNull(1) ? 0 : reader.GetInt32(1),
+ UnapprovedPhotos = reader.IsDBNull(2) ? 0 : reader.GetInt32(2)
+ });
+ }
+ return result;
+ }
}
+
diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs
index 54e0652..b0c32ca 100644
--- a/API/Data/Seed.cs
+++ b/API/Data/Seed.cs
@@ -10,6 +10,19 @@ namespace API.Data;
public class Seed
{
+ public static async Task SeedTagsAsync(DataContext context)
+ {
+ if (await context.Tags.AnyAsync()) return;
+
+ var tagData = await File.ReadAllTextAsync("Data/tags.json");
+ var tags = JsonSerializer.Deserialize>(tagData);
+
+ if (tags is not null)
+ {
+ context.Tags.AddRange(tags);
+ await context.SaveChangesAsync();
+ }
+ }
public static async Task SeedUsers(UserManager userManager, RoleManager roleManager)
{
if (await userManager.Users.AnyAsync()) return;
@@ -27,7 +40,7 @@ public static async Task SeedUsers(UserManager userManager, RoleManager
new() {Name = "Admin"},
new() {Name = "Moderator"}
};
- foreach(var role in roles)
+ foreach (var role in roles)
{
await roleManager.CreateAsync(role);
}
diff --git a/API/Data/TagsRepository.cs b/API/Data/TagsRepository.cs
new file mode 100644
index 0000000..775b25a
--- /dev/null
+++ b/API/Data/TagsRepository.cs
@@ -0,0 +1,68 @@
+using API.DTOs;
+using API.Entities;
+using API.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace API.Data;
+
+public class TagsRepository(DataContext context) : ITagsRepository
+{
+ private readonly DataContext _context = context;
+ public async Task> GetAllTagsAsync()
+ {
+ return await _context.Tags.ToListAsync();
+ }
+
+ public async Task> GetTagsByNamesAsync(List tags)
+ {
+ if (tags == null || !tags.Any())
+ return new List();
+
+ var distinctNames = tags
+ .Where(n => !string.IsNullOrWhiteSpace(n))
+ .Select(n => n.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var allTags = await _context.Tags.ToListAsync();
+
+ var matchingTags = allTags
+ .Where(t => distinctNames.Contains(t.Name, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ var existingNames = matchingTags
+ .Select(t => t.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var name in distinctNames)
+ {
+ if (!existingNames.Contains(name))
+ {
+ matchingTags.Add(new Tag { Name = name });
+ existingNames.Add(name);
+ }
+ }
+ return matchingTags;
+ }
+ public async Task RemoveTagByName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return;
+
+ var loweredName = name.ToLower();
+ var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name.ToLower() == loweredName);
+ if (tag != null)
+ {
+ _context.Tags.Remove(tag);
+ }
+ }
+
+ public async Task GetTagByNameAsync(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return null;
+
+ var loweredName = name.ToLower();
+ return await _context.Tags
+ .FirstOrDefaultAsync(t => t.Name.ToLower() == loweredName);
+ }
+}
diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs
index c376b54..177e5b9 100644
--- a/API/Data/UnitOfWork.cs
+++ b/API/Data/UnitOfWork.cs
@@ -2,17 +2,18 @@
namespace API.Data;
-public class UnitOfWork(DataContext context, IUserRepository userRepository,
- ILikesRepository likesRepository, IMessageRepository messageRepository, IPhotoRepository photoRepository) : IUnitOfWork
+public class UnitOfWork(DataContext context,
+ IUserRepository userRepository,
+ ILikesRepository likesRepository,
+ IMessageRepository messageRepository,
+ IPhotoRepository photoRepository,
+ ITagsRepository tagsRepository) : IUnitOfWork
{
public IUserRepository UserRepository => userRepository;
-
public IMessageRepository MessageRepository => messageRepository;
-
public ILikesRepository LikesRepository => likesRepository;
-
public IPhotoRepository PhotoRepository => photoRepository;
-
+ public ITagsRepository TagRepository => tagsRepository;
public async Task Complete()
{
return await context.SaveChangesAsync() > 0;
diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs
index 48a44c4..7f74aff 100644
--- a/API/Data/UserRepository.cs
+++ b/API/Data/UserRepository.cs
@@ -63,9 +63,7 @@ public async Task GetMemberAsync(string username, bool isCurrentUser)
.ProjectTo(mapper.ConfigurationProvider)
.AsQueryable();
if(isCurrentUser) query = query.IgnoreQueryFilters();
- var member = await query.FirstOrDefaultAsync();
- if (member == null)
- throw new InvalidOperationException("Member not found.");
+ var member = await query.FirstOrDefaultAsync() ?? throw new InvalidOperationException("Member not found.");
return member;
}
diff --git a/API/Data/tags.json b/API/Data/tags.json
new file mode 100644
index 0000000..8dbfdcf
--- /dev/null
+++ b/API/Data/tags.json
@@ -0,0 +1,7 @@
+[
+ { "Name": "Travel" },
+ { "Name": "Nature" },
+ { "Name": "Technology" },
+ { "Name": "Food" },
+ { "Name": "Architecture" }
+]
diff --git a/API/Entities/Photo.cs b/API/Entities/Photo.cs
index 8a561be..62da799 100644
--- a/API/Entities/Photo.cs
+++ b/API/Entities/Photo.cs
@@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace API.Entities;
+
[Table("Photos")]
public class Photo
{
@@ -10,7 +11,7 @@ public class Photo
public bool IsMain { get; set; }
public string? PublicId { get; set; }
public bool IsApproved { get; set; } = false;
-
+ public ICollection PhotoTags { get; set; } = [];
// Navigation property
public int AppUserId { get; set; }
public AppUser AppUser { get; set; } = null!;
diff --git a/API/Entities/PhotoTag.cs b/API/Entities/PhotoTag.cs
new file mode 100644
index 0000000..b2e46bd
--- /dev/null
+++ b/API/Entities/PhotoTag.cs
@@ -0,0 +1,9 @@
+namespace API.Entities;
+public class PhotoTag
+{
+ public int PhotoId { get; set; }
+ public Photo? Photo { get; set; }
+
+ public int TagId { get; set; }
+ public Tag? Tag { get; set; }
+}
diff --git a/API/Entities/Tag.cs b/API/Entities/Tag.cs
new file mode 100644
index 0000000..3aea305
--- /dev/null
+++ b/API/Entities/Tag.cs
@@ -0,0 +1,8 @@
+namespace API.Entities;
+
+public class Tag
+{
+ public int Id { get; set; }
+ public required string Name { get; set; }
+ public ICollection PhotoTags { get; set; } = [];
+}
diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs
index 9712f27..50af5de 100644
--- a/API/Extensions/ApplicationServiceExtensions.cs
+++ b/API/Extensions/ApplicationServiceExtensions.cs
@@ -3,6 +3,11 @@
using API.Helpers;
using API.Interfaces;
using API.Services;
+using API.Services._Account;
+using API.Services._Admin;
+using API.Services._Likes;
+using API.Services._Message;
+using API.Services._User;
using API.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -13,23 +18,35 @@ public static class ApplicationServiceExtensions
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config)
{
services.AddControllers();
+ services.AddApplicationInsightsTelemetry(options => {
+ options.ConnectionString = "InstrumentationKey=5cb65565-fbed-483f-bc29-94dc17e17ff2";
+ });
services.AddDbContext(opt =>
{
opt.UseSqlServer(config.GetConnectionString("DefaultConnection"));
});
services.AddCors();
+ // services
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ // repositories
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
services.Configure(config.GetSection("CloudinarySettings"));
services.AddSignalR();
- services.AddScoped();
services.AddSingleton();
return services;
}
diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs
index 3bc108d..6603271 100644
--- a/API/Helpers/AutoMapperProfiles.cs
+++ b/API/Helpers/AutoMapperProfiles.cs
@@ -10,20 +10,29 @@ public class AutoMapperProfiles : Profile
{
public AutoMapperProfiles()
{
+
CreateMap()
- .ForMember(dest => dest.PhotoUrl, opt => opt.MapFrom(src => src.DateOfBirth.CalculateAge()!))
- .ForMember(dest => dest.PhotoUrl, opt => opt.MapFrom(src => src.Photos.FirstOrDefault(x => x.IsMain)!.Url));
- CreateMap();
+ .ForMember(d => d.Age, o => o.MapFrom(s => s.DateOfBirth.CalculateAge()))
+ .ForMember(d => d.PhotoUrl, o => o.MapFrom(s => s.Photos.FirstOrDefault(x => x.IsMain)!.Url));
+ CreateMap()
+ .ForMember(dest => dest.Tags, opt => opt.MapFrom(src => src.PhotoTags.Select(pt => pt.Tag)));
CreateMap();
CreateMap();
CreateMap()
.ConvertUsing(s => DateOnly.Parse(s));
- CreateMap()
- .ForMember(d => d.SenderPhotoUrl,
- o => o.MapFrom(s => s.Sender.Photos.FirstOrDefault(x => x.IsMain)!.Url))
- .ForMember(d => d.RecipientPhotoUrl,
- o => o.MapFrom(s => s.Recipient.Photos.FirstOrDefault(x => x.IsMain)!.Url));
+ CreateMap().ForMember(d => d.SenderPhotoUrl, o => o.MapFrom(s => s.Sender.Photos.FirstOrDefault
+ (x => x.IsMain)!.Url));
+ CreateMap().ForMember(d => d.RecipientPhotoUrl, o => o.MapFrom(s => s.Recipient.Photos.FirstOrDefault
+ (x => x.IsMain)!.Url));
CreateMap().ConvertUsing(d => DateTime.SpecifyKind(d, DateTimeKind.Utc));
CreateMap().ConvertUsing(d => d.HasValue ? DateTime.SpecifyKind(d.Value, DateTimeKind.Utc) : null);
- }
+
+ CreateMap()
+ .ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.AppUser.UserName))
+ .ForMember(dest => dest.Tags, opt => opt.MapFrom(src => src.PhotoTags.Select(pt => pt.Tag!.Name)));
+
+ CreateMap();
+ CreateMap()
+ .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src));
+ }
}
diff --git a/API/Helpers/LogUserActivity.cs b/API/Helpers/LogUserActivity.cs
index 5cc414e..f5390d1 100644
--- a/API/Helpers/LogUserActivity.cs
+++ b/API/Helpers/LogUserActivity.cs
@@ -1,4 +1,3 @@
-using System;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -7,11 +6,11 @@ namespace API.Helpers;
public class LogUserActivity : IAsyncActionFilter
{
- private readonly UserService _userService;
+ private readonly UserServices _userServices;
- public LogUserActivity(UserService userService)
+ public LogUserActivity(UserServices userService)
{
- _userService = userService;
+ _userServices = userService;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
@@ -20,6 +19,6 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
if (context.HttpContext.User.Identity?.IsAuthenticated != true) return;
var userId = resultContext.HttpContext.User.GetUserId();
- await _userService.UpdateLastActive(userId);
+ await _userServices.UpdateLastActive(userId);
}
}
\ No newline at end of file
diff --git a/API/Interfaces/IAccountService.cs b/API/Interfaces/IAccountService.cs
new file mode 100644
index 0000000..a628631
--- /dev/null
+++ b/API/Interfaces/IAccountService.cs
@@ -0,0 +1,11 @@
+using System;
+using API.DTOs;
+
+namespace API.Interfaces;
+
+public interface IAccountService
+{
+ Task Register(RegisterDto registerDto);
+ Task Login(LoginDto loginDto);
+ Task UserExists(string username);
+}
diff --git a/API/Interfaces/IAdminService.cs b/API/Interfaces/IAdminService.cs
new file mode 100644
index 0000000..dce21fc
--- /dev/null
+++ b/API/Interfaces/IAdminService.cs
@@ -0,0 +1,18 @@
+using System;
+using API.DTOs;
+
+namespace API.Interfaces;
+
+public interface IAdminService
+{
+ Task> GetUsersWithRoles();
+ Task> EditRoles(string username, string roles);
+ Task> GetPhotosForModeration();
+ Task ApprovePhoto(int photoId);
+ Task RejectPhoto(int photoId);
+ Task