diff --git a/.github/workflows/prod_deploy.yml b/.github/workflows/prod_deploy.yml index e6463904..9094564d 100644 --- a/.github/workflows/prod_deploy.yml +++ b/.github/workflows/prod_deploy.yml @@ -20,10 +20,10 @@ jobs: # Build a Docker image of the application and tag the image with the $GITHUB_SHA. - name: Build backend image - run: docker build -t registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) ./backend + run: docker build -t registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) ./backend - name: Build web image - run: docker build -t registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) ./frontend + run: docker build -t registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) ./frontend - name: Log in to DigitalOcean Container Registry with short-lived credentials run: doctl registry login --expiry-seconds 1200 @@ -31,23 +31,26 @@ jobs: # Push the Docker image to registry - name: Push backend image - run: docker push registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) + run: docker push registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) - name: Push web image - run: docker push registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) + run: docker push registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) # tag as latest - name: Tag backend image - run: docker tag registry.digitalocean.com/programming-languages/backend:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages/backend:latest + run: docker tag registry.digitalocean.com/programming-languages-2/backend:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages-2/backend:latest - name: Tag web image - run: docker tag registry.digitalocean.com/programming-languages/web:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages/web:latest + run: docker tag registry.digitalocean.com/programming-languages-2/web:$(echo $GITHUB_SHA | head -c7) registry.digitalocean.com/programming-languages-2/web:latest # Push the Docker image to registry - name: Push backend image - run: docker push registry.digitalocean.com/programming-languages/backend:latest + run: docker push registry.digitalocean.com/programming-languages-2/backend:latest - name: Push web image - run: docker push registry.digitalocean.com/programming-languages/web:latest \ No newline at end of file + run: docker push registry.digitalocean.com/programming-languages-2/web:latest + + - name: Create deployment again to ensure both images are up to date + run: doctl apps create-deployment 58cc1048-2af2-42f8-a71f-5d1085113742 \ No newline at end of file diff --git a/README.md b/README.md index a603425d..889491b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Programming Languages Forum This is the repository for Group1 of SWE course in Fall'25. +Our deployment URL is here: https://programming-languages-forum-psrb6.ondigitalocean.app/ + ## About the Repository This is the repository for the Group1 of the Software Engineering course in Fall'25. The project is a web application that allows users to browse tags(programming paradigms, frameworks, languages, etc.), questions, and answers. The application has a frontend, backend and mobile application. The frontend is a web application that allows users to browse tags and questions about them. The backend is a REST API that serves the frontend and mobile application. The mobile application is a mobile application that allows users to browse tags and questions about them. @@ -114,24 +116,24 @@ docker compose build ```bash # for prod -docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages/web:latest -docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages/backend:latest +docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages-2/web:latest +docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages-2/backend:latest # for staging -docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages/web:staging -docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages/backend:staging +docker tag bounswe2024group1-451-web:latest registry.digitalocean.com/programming-languages-2/web:staging +docker tag bounswe2024group1-451-backend:latest registry.digitalocean.com/programming-languages-2/backend:staging ``` 3. Push images to the registry. ```bash # for prod -docker push registry.digitalocean.com/programming-languages/web:latest -docker push registry.digitalocean.com/programming-languages/backend:latest +docker push registry.digitalocean.com/programming-languages-2/web:latest +docker push registry.digitalocean.com/programming-languages-2/backend:latest # for staging -docker push registry.digitalocean.com/programming-languages/web:staging -docker push registry.digitalocean.com/programming-languages/backend:staging +docker push registry.digitalocean.com/programming-languages-2/web:staging +docker push registry.digitalocean.com/programming-languages-2/backend:staging ``` This will trigger a deployment on the DigitalOcean backend. diff --git a/backend/openapi.yaml b/backend/openapi.yaml deleted file mode 100644 index ae7eeca5..00000000 --- a/backend/openapi.yaml +++ /dev/null @@ -1,775 +0,0 @@ -openapi: 3.0.1 -info: - title: OpenAPI definition - version: v0 -servers: -- url: http://localhost:8080 - description: Generated server url -paths: - /users/{id}: - get: - tags: - - user-controller - operationId: getUserById - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseUserProfileResponseDto" - put: - tags: - - user-controller - operationId: updateUser - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UserProfileUpdateRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseUserProfileResponseDto" - /users/{id}/follow: - post: - tags: - - user-controller - operationId: followUser - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseUserProfileResponseDto" - /tags: - post: - tags: - - tag-controller - operationId: createTag - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateTagRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseGetTagDetailsResponseDto" - /questions: - post: - tags: - - question-controller - operationId: createQuestion - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateQuestionRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseCreateQuestionResponseDto" - /questions/{questionId}/bookmarks: - post: - tags: - - bookmark-controller - operationId: bookmarkQuestion - parameters: - - name: questionId - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseBookmarkQuestionResponseDto" - /questions/{id}/upvote: - post: - tags: - - vote-controller - operationId: upvoteQuestion - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseQuestionUpvoteResponseDto" - /questions/{id}/downvote: - post: - tags: - - vote-controller - operationId: downvoteQuestion - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseQuestionDownvoteResponseDto" - /execute-code: - post: - tags: - - code-execution-controller - operationId: executeCode - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CodeExecutionRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseCodeExecutionResponseDTO" - /auth/signup: - post: - tags: - - authentication-controller - operationId: signup - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/SignupRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseSignupResponseDto" - /auth/login: - post: - tags: - - authentication-controller - operationId: signin - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/SigninRequestDto" - required: true - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseSigninResponseDto" - /users/{id}/followers: - get: - tags: - - user-controller - operationId: getFollowers - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseListUserSummaryDto" - /users/me: - get: - tags: - - user-controller - operationId: getUser - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseSelfProfileResponseDto" - /test: - get: - tags: - - test-controller - operationId: test - responses: - "200": - description: OK - content: - '*/*': - schema: - type: string - /tags/{id}: - get: - tags: - - tag-controller - operationId: getTagDetails - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseGetTagDetailsResponseDto" - /users/{id}/unfollow: - delete: - tags: - - user-controller - operationId: unfollowUser - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseUserProfileResponseDto" - /questions/{id}/deleteUpvote: - delete: - tags: - - vote-controller - operationId: removeUpvote - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseQuestionDeleteUpvoteResponseDto" - /questions/{id}/deleteDownvote: - delete: - tags: - - vote-controller - operationId: removeDownvote - parameters: - - name: id - in: path - required: true - schema: - type: integer - format: int64 - responses: - "200": - description: OK - content: - '*/*': - schema: - $ref: "#/components/schemas/GenericApiResponseQuestionDeleteDownvoteResponseDto" -components: - schemas: - UserProfileUpdateRequestDto: - type: object - properties: - bio: - type: string - country: - type: string - ErrorResponse: - type: object - properties: - errorMessage: - type: string - stackTrace: - type: string - GenericApiResponseUserProfileResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/UserProfileResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - UserProfileResponseDto: - type: object - properties: - firstName: - type: string - lastName: - type: string - username: - type: string - email: - type: string - bio: - type: string - country: - type: string - answerCount: - type: integer - format: int64 - followersCount: - type: integer - format: int32 - followingCount: - type: integer - format: int32 - selfFollowing: - type: boolean - reputationPoints: - type: integer - format: int32 - CreateTagRequestDto: - type: object - properties: - name: - type: string - description: - type: string - GenericApiResponseGetTagDetailsResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/GetTagDetailsResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - GetTagDetailsResponseDto: - type: object - properties: - tagId: - type: integer - format: int64 - name: - type: string - description: - type: string - questionCount: - type: integer - format: int64 - followerCount: - type: integer - format: int64 - following: - type: boolean - tagType: - type: string - CreateQuestionRequestDto: - type: object - properties: - title: - type: string - content: - type: string - tagIds: - type: array - items: - type: integer - format: int64 - AuthorDto: - type: object - properties: - id: - type: integer - format: int64 - username: - type: string - reputationPoints: - type: integer - format: int64 - profilePicture: - type: string - name: - type: string - CreateQuestionResponseDto: - type: object - properties: - id: - type: integer - format: int64 - title: - type: string - content: - type: string - author: - $ref: "#/components/schemas/AuthorDto" - createdAt: - type: string - updatedAt: - type: string - tags: - type: array - items: - $ref: "#/components/schemas/TagDto" - upvoteCount: - type: integer - format: int64 - downvoteCount: - type: integer - format: int64 - GenericApiResponseCreateQuestionResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/CreateQuestionResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - TagDto: - type: object - properties: - id: - type: integer - format: int64 - name: - type: string - BookmarkQuestionResponseDto: - type: object - properties: - id: - type: integer - format: int64 - title: - type: string - upvoteCount: - type: integer - format: int64 - downvoteCount: - type: integer - format: int64 - GenericApiResponseBookmarkQuestionResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/BookmarkQuestionResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - GenericApiResponseQuestionUpvoteResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/QuestionUpvoteResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - QuestionUpvoteResponseDto: - type: object - properties: - questionId: - type: integer - format: int64 - upvoteCount: - type: integer - format: int64 - GenericApiResponseQuestionDownvoteResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/QuestionDownvoteResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - QuestionDownvoteResponseDto: - type: object - properties: - questionId: - type: integer - format: int64 - downvoteCount: - type: integer - format: int64 - CodeExecutionRequestDto: - type: object - properties: - code: - type: string - language: - type: string - input: - type: string - CodeExecutionResponseDTO: - type: object - properties: - status: - type: string - output: - type: string - executionTime: - type: number - format: double - GenericApiResponseCodeExecutionResponseDTO: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/CodeExecutionResponseDTO" - error: - $ref: "#/components/schemas/ErrorResponse" - SignupRequestDto: - type: object - properties: - username: - type: string - email: - type: string - password: - type: string - firstName: - type: string - lastName: - type: string - country: - type: string - GenericApiResponseSignupResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/SignupResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - SignupResponseDto: - type: object - properties: - token: - type: string - SigninRequestDto: - type: object - properties: - usernameOrEmail: - type: string - password: - type: string - GenericApiResponseSigninResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/SigninResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - SigninResponseDto: - type: object - properties: - token: - type: string - GenericApiResponseListUserSummaryDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - type: array - items: - $ref: "#/components/schemas/UserSummaryDto" - error: - $ref: "#/components/schemas/ErrorResponse" - UserSummaryDto: - type: object - properties: - id: - type: integer - format: int64 - username: - type: string - reputationPoints: - type: integer - format: int32 - GenericApiResponseSelfProfileResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/SelfProfileResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - SelfProfileResponseDto: - type: object - properties: - id: - type: integer - format: int64 - firstName: - type: string - lastName: - type: string - username: - type: string - email: - type: string - bio: - type: string - country: - type: string - answerCount: - type: integer - format: int64 - followersCount: - type: integer - format: int32 - followingCount: - type: integer - format: int32 - reputationPoints: - type: integer - format: int32 - GenericApiResponseQuestionDeleteUpvoteResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/QuestionDeleteUpvoteResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - QuestionDeleteUpvoteResponseDto: - type: object - properties: - questionId: - type: integer - format: int64 - upvoteCount: - type: integer - format: int64 - downvoteCount: - type: integer - format: int64 - GenericApiResponseQuestionDeleteDownvoteResponseDto: - type: object - properties: - status: - type: integer - format: int32 - message: - type: string - data: - $ref: "#/components/schemas/QuestionDeleteDownvoteResponseDto" - error: - $ref: "#/components/schemas/ErrorResponse" - QuestionDeleteDownvoteResponseDto: - type: object - properties: - questionId: - type: integer - format: int64 - upvoteCount: - type: integer - format: int64 - downvoteCount: - type: integer - format: int64 diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/QuestionController.java b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/QuestionController.java index 39ed19db..352590c0 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/QuestionController.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/QuestionController.java @@ -1,29 +1,43 @@ package com.group1.programminglanguagesforum.Controllers; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + import com.group1.programminglanguagesforum.Constants.EndpointConstants; import com.group1.programminglanguagesforum.DTOs.Requests.CreateQuestionRequestDto; import com.group1.programminglanguagesforum.DTOs.Requests.DifficultyLevelRequestDto; import com.group1.programminglanguagesforum.DTOs.Requests.UpdateQuestionRequestDto; -import com.group1.programminglanguagesforum.DTOs.Responses.*; -import com.group1.programminglanguagesforum.Exceptions.ExceptionResponseHandler; +import com.group1.programminglanguagesforum.DTOs.Responses.CreateQuestionResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.ErrorResponse; +import com.group1.programminglanguagesforum.DTOs.Responses.GenericApiResponse; +import com.group1.programminglanguagesforum.DTOs.Responses.GetQuestionDetailsResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.QuestionRateResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.QuestionSummaryDto; import com.group1.programminglanguagesforum.Entities.DifficultyLevel; import com.group1.programminglanguagesforum.Entities.Question; +import com.group1.programminglanguagesforum.Exceptions.ExceptionResponseHandler; import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Services.QuestionDifficultyRateService; import com.group1.programminglanguagesforum.Services.QuestionService; import com.group1.programminglanguagesforum.Util.ApiResponseBuilder; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api/v1") @@ -59,7 +73,8 @@ public ResponseEntity> getQues .errorMessage(e.getMessage()) .stackTrace(Arrays.toString(e.getStackTrace())) .build(); - GenericApiResponse response = ApiResponseBuilder.buildErrorResponse(GetQuestionDetailsResponseDto.class, e.getMessage(), 404, errorResponse); + GenericApiResponse response = ApiResponseBuilder + .buildErrorResponse(GetQuestionDetailsResponseDto.class, e.getMessage(), 404, errorResponse); return buildResponse(response, org.springframework.http.HttpStatus.NOT_FOUND); } } @@ -113,7 +128,7 @@ public ResponseEntity>> searchQuestions( Page questionPage = questionService.searchQuestions(query, tags, difficulty, page, pageSize); List questionSummaries = questionPage.getContent().stream() - .map(questionService::mapToQuestionSummary) + .map(QuestionService::mapToQuestionSummary) .toList(); Map response = new HashMap<>(); @@ -142,8 +157,7 @@ public ResponseEntity> rateQuestion( return buildResponse(response, org.springframework.http.HttpStatus.OK); } catch (NoSuchElementException e) { return ExceptionResponseHandler.NoSuchElementException(e); - } - catch (UnauthorizedAccessException e) { + } catch (UnauthorizedAccessException e) { return ExceptionResponseHandler.UnauthorizedAccessException(e); } } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java index aa0d72a7..0b13aa85 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java @@ -12,6 +12,7 @@ import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Services.TagService; import com.group1.programminglanguagesforum.Services.UserContextService; +import com.group1.programminglanguagesforum.Services.UserService; import com.group1.programminglanguagesforum.Util.ApiResponseBuilder; import jakarta.persistence.EntityExistsException; @@ -26,6 +27,7 @@ import java.util.Arrays; import java.util.NoSuchElementException; +import java.util.Optional; @RestController @@ -35,6 +37,7 @@ public class TagController extends BaseController { private final TagService tagService; private final UserContextService userContextService; + private final UserService userService; @GetMapping(value = EndpointConstants.TagEndpoints.SEARCH) public ResponseEntity> tagSearch( @@ -94,6 +97,13 @@ public ResponseEntity> getTagDetail @PostMapping(value = EndpointConstants.TagEndpoints.BASE_PATH) public ResponseEntity> createTag(@RequestBody CreateTagRequestDto dto){ try{ + User user = userContextService.getCurrentUser(); + if(userService.calculateReputation(user) < 50){ + return ExceptionResponseHandler.IllegalArgumentException( + new IllegalArgumentException("User does not have enough reputation to create a tag which should be at least 50") + ); + } + GetTagDetailsResponseDto tagDetails = tagService.createTag(dto); GenericApiResponse response = GenericApiResponse.builder() .status(201) @@ -115,6 +125,8 @@ public ResponseEntity> createTag(@R .build(); ApiResponseBuilder.buildErrorResponse(GetTagDetailsResponseDto.class, "Invalid tag type", 400, errorResponse); return buildResponse(response, HttpStatus.BAD_REQUEST); + } catch (UnauthorizedAccessException e) { + return ExceptionResponseHandler.UnauthorizedAccessException(e); } } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetTagDetailsResponseDto.java b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetTagDetailsResponseDto.java index d4ff5b7c..47e81f1a 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetTagDetailsResponseDto.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetTagDetailsResponseDto.java @@ -1,9 +1,14 @@ package com.group1.programminglanguagesforum.DTOs.Responses; -import lombok.*; -import lombok.experimental.SuperBuilder; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + @SuperBuilder @Getter @Setter @@ -21,5 +26,5 @@ public class GetTagDetailsResponseDto { private Boolean following = false; private String tagType; @Builder.Default - private List relatedQuestions = null ; + private List relatedQuestions = null; } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java index da282981..a2844faf 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/TagRepository.java @@ -1,7 +1,7 @@ package com.group1.programminglanguagesforum.Repositories; +import java.util.List; -import com.group1.programminglanguagesforum.Entities.Tag; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,13 +9,19 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.List; +import com.group1.programminglanguagesforum.Entities.Tag; @Repository -public interface TagRepository extends JpaRepository { +public interface TagRepository extends JpaRepository { List findAllByIdIn(List ids); + @Query("SELECT t FROM Tag t " + + "LEFT JOIN Question q ON t MEMBER OF q.tags " + + "WHERE LOWER(t.tagName) LIKE LOWER(CONCAT('%', :tagName, '%')) " + + "GROUP BY t.id " + + "ORDER BY COUNT(q.id) DESC") Page findTagsByTagNameContainingIgnoreCase(String tagName, Pageable pageable); + @Query("SELECT t FROM Tag t JOIN t.followers u WHERE u.id = :userId") List findTagByFollowers(@Param("userId") Long userId); } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java b/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java index 66454e12..6db81f66 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java @@ -1,5 +1,20 @@ package com.group1.programminglanguagesforum.Services; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + import com.group1.programminglanguagesforum.DTOs.Requests.CreateQuestionRequestDto; import com.group1.programminglanguagesforum.DTOs.Requests.UpdateQuestionRequestDto; import com.group1.programminglanguagesforum.DTOs.Responses.AuthorDto; @@ -7,7 +22,11 @@ import com.group1.programminglanguagesforum.DTOs.Responses.GetQuestionDetailsResponseDto; import com.group1.programminglanguagesforum.DTOs.Responses.QuestionSummaryDto; import com.group1.programminglanguagesforum.DTOs.Responses.TagDto; -import com.group1.programminglanguagesforum.Entities.*; +import com.group1.programminglanguagesforum.Entities.DifficultyLevel; +import com.group1.programminglanguagesforum.Entities.Question; +import com.group1.programminglanguagesforum.Entities.QuestionDifficultyRate; +import com.group1.programminglanguagesforum.Entities.Tag; +import com.group1.programminglanguagesforum.Entities.User; import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Repositories.BookmarkRepository; import com.group1.programminglanguagesforum.Repositories.QuestionRepository; @@ -15,225 +34,226 @@ import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; - -import java.text.SimpleDateFormat; -import java.util.*; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor public class QuestionService { - private final QuestionRepository questionRepository; - private final UserContextService userContextService; - private final TagService tagService; - private final BookmarkRepository bookmarkRepository; - private final VoteRepository voteRepository; - private final QuestionDifficultyRateService questionDifficultyRateService; - - public Optional findById(Long id) { - return questionRepository.findById(id); - } - - public List findByAuthorId(Long authorId) { - return questionRepository.findByAuthorId(authorId).stream() - .map(this::mapToQuestionSummary) - .collect(Collectors.toList()); - } - - public CreateQuestionResponseDto createQuestion(CreateQuestionRequestDto dto) - throws UnauthorizedAccessException { - List tagIds = dto.getTagIds(); - Set existingTags = new HashSet<>(tagService.findAllByIdIn(tagIds)); - User currentUser = userContextService.getCurrentUser(); - Date date = new Date(); - Question question = Question.builder() - .title(dto.getTitle()) - .questionBody(dto.getContent()) - .difficulty(dto.getDifficulty()) - .askedBy(currentUser) - .createdAt(date) - .updatedAt(date) - .likeCount(0L) - .commentCount(0L) - .tags(existingTags) - .votes(new ArrayList<>()) - .build(); - questionRepository.save(question); - List tags = existingTags.stream().map(tag -> TagDto.builder() - .id(tag.getId()) - .name(tag.getTagName()) - .build()).toList(); - - return CreateQuestionResponseDto.builder() - .id(question.getId()) - .title(question.getTitle()) - .content(question.getQuestionBody()) - .difficulty(question.getDifficulty()) - .tags(tags) - .author(AuthorDto.builder() - .id(currentUser.getId()) - .username(currentUser.getUsername()) - .reputationPoints(currentUser.getReputationPoints()) - .name(currentUser.getFirstName() + " " + currentUser.getLastName()) - .build()) - .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) - .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) - .upvoteCount(0L) - .downvoteCount(0L) - .build(); - - } - - public GetQuestionDetailsResponseDto getQuestion(Long id) throws NoSuchElementException { - User currentUser; - try { - currentUser = userContextService.getCurrentUser(); - } catch (UnauthorizedAccessException e) { - currentUser = null; + private final QuestionRepository questionRepository; + private final UserContextService userContextService; + private final TagService tagService; + private final BookmarkRepository bookmarkRepository; + private final VoteRepository voteRepository; + private final QuestionDifficultyRateService questionDifficultyRateService; + + public Optional findById(Long id) { + return questionRepository.findById(id); } - Optional questionOptional = questionRepository.findById(id); - if (questionOptional.isEmpty()) { - throw new NoSuchElementException("Question not found"); + public List findByAuthorId(Long authorId) { + return questionRepository.findByAuthorId(authorId).stream() + .map(QuestionService::mapToQuestionSummary) + .collect(Collectors.toList()); } - Question question = questionOptional.get(); - boolean selfQuestion = (currentUser != null && currentUser.getId().equals(question.getAskedBy().getId())); - boolean isBookmarked = (currentUser != null && bookmarkRepository.existsByUserAndQuestion(currentUser, question)); - Integer selfVoted = (currentUser != null && voteRepository.findByUserAndQuestion(currentUser, question).isPresent() ? voteRepository.findByUserAndQuestion(currentUser, question).get().isUpvote() ? 1 : -1 : 0); - QuestionDifficultyRateService.QuestionRateCounts record = questionDifficultyRateService.getResult(); - long easyCount = record.easyCount(); - long mediumCount = record.mediumCount(); - long hardCount = record.hardCount(); - DifficultyLevel lastRatedDifficulty = questionDifficultyRateService.getQuestionDifficultyRate(questionOptional.get(), currentUser).map(QuestionDifficultyRate::getDifficulty).orElse(null); - - return GetQuestionDetailsResponseDto.builder() - .id(question.getId()) - .title(question.getTitle()) - .content(question.getQuestionBody()) - .difficulty(question.getDifficulty()) - .selfDifficultyVote(lastRatedDifficulty) - .likeCount(question.getUpvoteCount()) - .dislikeCount(question.getDownvoteCount()) - .commentCount(question.getCommentCount()) - .selfQuestion(selfQuestion) - .selfVoted(selfVoted) - .easyCount(easyCount) - .mediumCount(mediumCount) - .hardCount(hardCount) - .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) - .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) - .author(AuthorDto.builder() - .id(question.getAskedBy().getId()) - .username(question.getAskedBy().getUsername()) - .reputationPoints(question.getAskedBy().getReputationPoints()) - .name(question.getAskedBy().getFirstName() + " " + question.getAskedBy().getLastName()) - .build()) - .rating(0L) - .tags(question.getTags().stream().map(tag -> TagDto.builder() - .id(tag.getId()) - .name(tag.getTagName()) - .build()).toList()) - .answerCount((long) question.getAnswers().size()) - .bookmarked(isBookmarked) - .build(); - - } - - public String deleteQuestion(Long id) { - Optional questionOptional = questionRepository.findById(id); - if (questionOptional.isEmpty()) { - throw new NoSuchElementException("Question not found"); + public CreateQuestionResponseDto createQuestion(CreateQuestionRequestDto dto) + throws UnauthorizedAccessException { + List tagIds = dto.getTagIds(); + Set existingTags = new HashSet<>(tagService.findAllByIdIn(tagIds)); + User currentUser = userContextService.getCurrentUser(); + Date date = new Date(); + Question question = Question.builder() + .title(dto.getTitle()) + .questionBody(dto.getContent()) + .difficulty(dto.getDifficulty()) + .askedBy(currentUser) + .createdAt(date) + .updatedAt(date) + .likeCount(0L) + .commentCount(0L) + .tags(existingTags) + .votes(new ArrayList<>()) + .build(); + questionRepository.save(question); + List tags = existingTags.stream().map(tag -> TagDto.builder() + .id(tag.getId()) + .name(tag.getTagName()) + .build()).toList(); + + return CreateQuestionResponseDto.builder() + .id(question.getId()) + .title(question.getTitle()) + .content(question.getQuestionBody()) + .difficulty(question.getDifficulty()) + .tags(tags) + .author(AuthorDto.builder() + .id(currentUser.getId()) + .username(currentUser.getUsername()) + .reputationPoints(currentUser.getReputationPoints()) + .name(currentUser.getFirstName() + " " + currentUser.getLastName()) + .build()) + .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) + .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) + .upvoteCount(0L) + .downvoteCount(0L) + .build(); + } - Question question = questionOptional.get(); - questionRepository.delete(question); - return "Question deleted successfully"; - } - - public CreateQuestionResponseDto updateQuestion(Long id, UpdateQuestionRequestDto dto) { - Optional questionOptional = questionRepository.findById(id); - if (questionOptional.isEmpty()) { - throw new NoSuchElementException("Question not found"); + + public GetQuestionDetailsResponseDto getQuestion(Long id) throws NoSuchElementException { + User currentUser; + try { + currentUser = userContextService.getCurrentUser(); + } catch (UnauthorizedAccessException e) { + currentUser = null; + } + + Optional questionOptional = questionRepository.findById(id); + if (questionOptional.isEmpty()) { + throw new NoSuchElementException("Question not found"); + } + + Question question = questionOptional.get(); + boolean selfQuestion = (currentUser != null + && currentUser.getId().equals(question.getAskedBy().getId())); + boolean isBookmarked = (currentUser != null + && bookmarkRepository.existsByUserAndQuestion(currentUser, question)); + Integer selfVoted = (currentUser != null + && voteRepository.findByUserAndQuestion(currentUser, question).isPresent() + ? voteRepository.findByUserAndQuestion(currentUser, question).get() + .isUpvote() ? 1 : -1 + : 0); + QuestionDifficultyRateService.QuestionRateCounts record = questionDifficultyRateService.getResult(); + long easyCount = record.easyCount(); + long mediumCount = record.mediumCount(); + long hardCount = record.hardCount(); + DifficultyLevel lastRatedDifficulty = questionDifficultyRateService + .getQuestionDifficultyRate(questionOptional.get(), currentUser) + .map(QuestionDifficultyRate::getDifficulty).orElse(null); + + return GetQuestionDetailsResponseDto.builder() + .id(question.getId()) + .title(question.getTitle()) + .content(question.getQuestionBody()) + .difficulty(question.getDifficulty()) + .selfDifficultyVote(lastRatedDifficulty) + .likeCount(question.getUpvoteCount()) + .dislikeCount(question.getDownvoteCount()) + .commentCount(question.getCommentCount()) + .selfQuestion(selfQuestion) + .selfVoted(selfVoted) + .easyCount(easyCount) + .mediumCount(mediumCount) + .hardCount(hardCount) + .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) + .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) + .author(AuthorDto.builder() + .id(question.getAskedBy().getId()) + .username(question.getAskedBy().getUsername()) + .reputationPoints(question.getAskedBy().getReputationPoints()) + .name(question.getAskedBy().getFirstName() + " " + + question.getAskedBy().getLastName()) + .build()) + .rating(0L) + .tags(question.getTags().stream().map(tag -> TagDto.builder() + .id(tag.getId()) + .name(tag.getTagName()) + .build()).toList()) + .answerCount((long) question.getAnswers().size()) + .bookmarked(isBookmarked) + .build(); + } - Date date = new Date(); - Question question = questionOptional.get(); - List tagIds = dto.getTags(); - Set existingTags = new HashSet<>(tagService.findAllByIdIn(tagIds)); - question.setTitle(dto.getTitle()); - question.setQuestionBody(dto.getContent()); - question.setTags(existingTags); - question.setUpdatedAt(date); - question.setDifficulty(dto.getDifficulty()); - questionRepository.save(question); - List tags = existingTags.stream().map(tag -> TagDto.builder() - .id(tag.getId()) - .name(tag.getTagName()) - .build()).toList(); - return CreateQuestionResponseDto.builder() - .id(question.getId()) - .title(question.getTitle()) - .content(question.getQuestionBody()) - .difficulty(question.getDifficulty()) - .tags(tags) - .author(AuthorDto.builder() - .id(question.getAskedBy().getId()) - .username(question.getAskedBy().getUsername()) - .reputationPoints(question.getAskedBy().getReputationPoints()) - .name(question.getAskedBy().getFirstName() + " " - + question.getAskedBy().getLastName()) - .build()) - .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) - .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) - .upvoteCount(question.getUpvoteCount()) - .downvoteCount(question.getDownvoteCount()) - .build(); - - } - - public Page searchQuestions( - String query, - String tagIdsStr, - DifficultyLevel difficulty, - int page, - int pageSize) { - - List tagIds = null; - if (tagIdsStr != null && !tagIdsStr.isEmpty()) { - tagIds = Arrays.stream(tagIdsStr.split(",")) - .map(Long::parseLong) - .collect(Collectors.toList()); + + public String deleteQuestion(Long id) { + Optional questionOptional = questionRepository.findById(id); + if (questionOptional.isEmpty()) { + throw new NoSuchElementException("Question not found"); + } + Question question = questionOptional.get(); + questionRepository.delete(question); + return "Question deleted successfully"; } - PageRequest pageable = PageRequest.of(page - 1, pageSize); - return questionRepository.searchQuestions(query, tagIds, difficulty, pageable); - } - - public QuestionSummaryDto mapToQuestionSummary(Question question) { - return QuestionSummaryDto.builder() - .id(question.getId()) - .title(question.getTitle()) - .content(question.getQuestionBody()) - .difficulty(question.getDifficulty()) - .upvoteCount(question.getUpvoteCount()) - .downvoteCount(question.getDownvoteCount()) - .answerCount((long) question.getAnswers().size()) - .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) - .author(AuthorDto.builder() - .id(question.getAskedBy().getId()) - .username(question.getAskedBy().getUsername()) - .name(question.getAskedBy().getFirstName() + " " - + question.getAskedBy().getLastName()) - .reputationPoints(question.getAskedBy().getReputationPoints()) - .build()) - .tags(question.getTags().stream() - .map(tag -> TagDto.builder() + public CreateQuestionResponseDto updateQuestion(Long id, UpdateQuestionRequestDto dto) { + Optional questionOptional = questionRepository.findById(id); + if (questionOptional.isEmpty()) { + throw new NoSuchElementException("Question not found"); + } + Date date = new Date(); + Question question = questionOptional.get(); + List tagIds = dto.getTags(); + Set existingTags = new HashSet<>(tagService.findAllByIdIn(tagIds)); + question.setTitle(dto.getTitle()); + question.setQuestionBody(dto.getContent()); + question.setTags(existingTags); + question.setUpdatedAt(date); + question.setDifficulty(dto.getDifficulty()); + questionRepository.save(question); + List tags = existingTags.stream().map(tag -> TagDto.builder() .id(tag.getId()) .name(tag.getTagName()) - .build()) - .collect(Collectors.toList())) - .build(); - } + .build()).toList(); + return CreateQuestionResponseDto.builder() + .id(question.getId()) + .title(question.getTitle()) + .content(question.getQuestionBody()) + .difficulty(question.getDifficulty()) + .tags(tags) + .author(AuthorDto.builder() + .id(question.getAskedBy().getId()) + .username(question.getAskedBy().getUsername()) + .reputationPoints(question.getAskedBy().getReputationPoints()) + .name(question.getAskedBy().getFirstName() + " " + + question.getAskedBy().getLastName()) + .build()) + .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) + .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) + .upvoteCount(question.getUpvoteCount()) + .downvoteCount(question.getDownvoteCount()) + .build(); + + } + + public Page searchQuestions( + String query, + String tagIdsStr, + DifficultyLevel difficulty, + int page, + int pageSize) { + + List tagIds = null; + if (tagIdsStr != null && !tagIdsStr.isEmpty()) { + tagIds = Arrays.stream(tagIdsStr.split(",")) + .map(Long::parseLong) + .collect(Collectors.toList()); + } + + PageRequest pageable = PageRequest.of(page - 1, pageSize); + return questionRepository.searchQuestions(query, tagIds, difficulty, pageable); + } + + public static QuestionSummaryDto mapToQuestionSummary(Question question) { + return QuestionSummaryDto.builder() + .id(question.getId()) + .title(question.getTitle()) + .content(question.getQuestionBody()) + .difficulty(question.getDifficulty()) + .upvoteCount(question.getUpvoteCount()) + .downvoteCount(question.getDownvoteCount()) + .answerCount((long) question.getAnswers().size()) + .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) + .author(AuthorDto.builder() + .id(question.getAskedBy().getId()) + .username(question.getAskedBy().getUsername()) + .name(question.getAskedBy().getFirstName() + " " + + question.getAskedBy().getLastName()) + .reputationPoints(question.getAskedBy().getReputationPoints()) + .build()) + .tags(question.getTags().stream() + .map(tag -> TagDto.builder() + .id(tag.getId()) + .name(tag.getTagName()) + .build()) + .collect(Collectors.toList())) + .build(); + } } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java b/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java index 4483efc0..3647c828 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java @@ -1,22 +1,36 @@ package com.group1.programminglanguagesforum.Services; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import org.modelmapper.ModelMapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + import com.group1.programminglanguagesforum.DTOs.Requests.CreateTagRequestDto; -import com.group1.programminglanguagesforum.DTOs.Responses.*; -import com.group1.programminglanguagesforum.Entities.*; +import com.group1.programminglanguagesforum.DTOs.Responses.GetProgrammingLanguageTagResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.GetProgrammingParadigmResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.GetTagDetailsResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.QuestionSummaryDto; +import com.group1.programminglanguagesforum.DTOs.Responses.SelfProfileResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.TagDto; +import com.group1.programminglanguagesforum.Entities.ComputerScienceTermTag; +import com.group1.programminglanguagesforum.Entities.ProgrammingLanguagesTag; +import com.group1.programminglanguagesforum.Entities.ProgrammingParadigmTag; +import com.group1.programminglanguagesforum.Entities.Question; +import com.group1.programminglanguagesforum.Entities.SoftwareLibraryTag; +import com.group1.programminglanguagesforum.Entities.Tag; +import com.group1.programminglanguagesforum.Entities.TagType; +import com.group1.programminglanguagesforum.Entities.User; +import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Repositories.QuestionRepository; import com.group1.programminglanguagesforum.Repositories.TagRepository; import com.group1.programminglanguagesforum.Repositories.UserRepository; import jakarta.persistence.EntityExistsException; import lombok.RequiredArgsConstructor; -import org.modelmapper.ModelMapper; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.NoSuchElementException; -import java.util.Optional; @Service @RequiredArgsConstructor @@ -25,6 +39,7 @@ public class TagService { private final ModelMapper modelMapper; private final QuestionRepository questionRepository; private final UserRepository userRepository; + private final UserContextService userContextService; public List findAllByIdIn(List tagIds) { return tagRepository.findAllByIdIn(tagIds); @@ -59,6 +74,10 @@ public GetTagDetailsResponseDto createTag(CreateTagRequestDto dto) { .build(); } + public boolean isTagFollowed(User user, Long tagId) { + return user.getFollowedTags().stream().anyMatch(t -> t.getId().equals(tagId)); + } + public GetTagDetailsResponseDto getTagDetails(Long tagId) { Optional tag = tagRepository.findById(tagId); if (tag.isEmpty()) { @@ -67,11 +86,21 @@ public GetTagDetailsResponseDto getTagDetails(Long tagId) { Tag tagEntity = tag.get(); TagType tagType = getTagType(tagEntity); List questions = questionRepository.findQuestionsByTagId(tagId); - List relatedQuestions = questions.stream() - .map(question -> modelMapper.map(question, GetQuestionWithTagDto.class)) + List relatedQuestions = questions.stream() + .map(QuestionService::mapToQuestionSummary) .toList(); Long questionCount = (long) questions.size(); + boolean following = false; + try { + // userContextService is null when testing + if (userContextService != null && userContextService.getCurrentUser() != null) { + following = isTagFollowed(userContextService.getCurrentUser(), tagId); + } + } catch (UnauthorizedAccessException e) { + following = false; + } + if (tagType == TagType.PROGRAMMING_LANGUAGE) { ProgrammingLanguagesTag languageTag = (ProgrammingLanguagesTag) tagEntity; GetProgrammingLanguageTagResponseDto responseDto = modelMapper.map(languageTag, @@ -79,6 +108,7 @@ public GetTagDetailsResponseDto getTagDetails(Long tagId) { responseDto.setTagType(tagType.toString()); responseDto.setRelatedQuestions(relatedQuestions); responseDto.setQuestionCount(questionCount); + responseDto.setFollowing(following); return responseDto; } else if (tagType == TagType.PROGRAMMING_PARADIGM) { ProgrammingParadigmTag paradigmTag = (ProgrammingParadigmTag) tagEntity; @@ -87,6 +117,7 @@ public GetTagDetailsResponseDto getTagDetails(Long tagId) { responseDto.setTagType(tagType.toString()); responseDto.setRelatedQuestions(relatedQuestions); responseDto.setQuestionCount(questionCount); + responseDto.setFollowing(following); return responseDto; } @@ -97,10 +128,11 @@ public GetTagDetailsResponseDto getTagDetails(Long tagId) { .tagType(getTagType(tagEntity).toString()) .relatedQuestions(relatedQuestions) .questionCount(questionCount) - + .following(following) .build(); } + public List getFollowedTags(Long userId) { return tagRepository.findTagByFollowers(userId).stream() .map(tag -> SelfProfileResponseDto.FollowedTags.builder() diff --git a/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java b/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java index d1bef799..4b8fbf91 100644 --- a/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java +++ b/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java @@ -1,27 +1,37 @@ package com.group1.programminglanguagesforum.Services; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.modelmapper.ModelMapper; + import com.group1.programminglanguagesforum.DTOs.Requests.CreateTagRequestDto; import com.group1.programminglanguagesforum.DTOs.Responses.GetQuestionWithTagDto; import com.group1.programminglanguagesforum.DTOs.Responses.GetTagDetailsResponseDto; import com.group1.programminglanguagesforum.Entities.DifficultyLevel; import com.group1.programminglanguagesforum.Entities.Question; import com.group1.programminglanguagesforum.Entities.Tag; +import com.group1.programminglanguagesforum.Entities.User; import com.group1.programminglanguagesforum.Repositories.QuestionRepository; import com.group1.programminglanguagesforum.Repositories.TagRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.modelmapper.ModelMapper; - -import java.util.Arrays; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; class TagServiceTest { @@ -89,11 +99,45 @@ void testCreateTag() { @Test void testGetTagDetails_Success() { Long tagId = 1L; + Date mockDate = new Date(); + + // Create a mock user with all required fields + User mockUser = User.builder() + .id(1L) + .username("testUser") + .email("test@example.com") // Add required email + .firstName("Test") + .lastName("User") + .reputationPoints(0L) + .build(); Tag mockTag = new Tag(1L, null, "Tag1", "Description1", null); - List mockQuestions = Arrays.asList( - new Question(1L, "Question1", "Body1", DifficultyLevel.EASY, 0L, 0L, null, null, null, null, null,null), - new Question(2L, "Question2", "Body2", DifficultyLevel.MEDIUM, 0L, 0L, null, null, null, null, null,null)); + + Question q1 = Question.builder() + .id(1L) + .title("Question1") + .questionBody("Body1") + .difficulty(DifficultyLevel.EASY) + .likeCount(0L) + .commentCount(0L) + .createdAt(mockDate) + .updatedAt(mockDate) + .askedBy(mockUser) + .build(); + + Question q2 = Question.builder() + .id(2L) + .title("Question2") + .questionBody("Body2") + .difficulty(DifficultyLevel.MEDIUM) + .likeCount(0L) + .commentCount(0L) + .createdAt(mockDate) + .updatedAt(mockDate) + .askedBy(mockUser) + .build(); + + List mockQuestions = Arrays.asList(q1, q2); when(tagRepository.findById(tagId)).thenReturn(Optional.of(mockTag)); when(questionRepository.findQuestionsByTagId(tagId)).thenReturn(mockQuestions); diff --git a/frontend/src/assets/placeholder_profile.png b/frontend/src/assets/placeholder_profile.png new file mode 100644 index 00000000..09892098 Binary files /dev/null and b/frontend/src/assets/placeholder_profile.png differ diff --git a/frontend/src/components/AnswerCard.tsx b/frontend/src/components/AnswerCard.tsx index a5736d3e..2d70f055 100644 --- a/frontend/src/components/AnswerCard.tsx +++ b/frontend/src/components/AnswerCard.tsx @@ -2,6 +2,7 @@ import { Card } from "@/components/ui/card"; import { ArrowRight, CornerDownRight, Star } from "lucide-react"; import React from "react"; import { Link } from "react-router-dom"; +import placeholderProfile from "@/assets/placeholder_profile.png"; interface AnswerCardProps { id: number; @@ -24,7 +25,7 @@ export const AnswerCard: React.FC = ({ author, }) => { return ( - +

{title} @@ -48,7 +49,8 @@ export const AnswerCard: React.FC = ({
{"Profile diff --git a/frontend/src/components/AnswerItem.tsx b/frontend/src/components/AnswerItem.tsx index 9ffe68fc..f750d95d 100644 --- a/frontend/src/components/AnswerItem.tsx +++ b/frontend/src/components/AnswerItem.tsx @@ -6,6 +6,7 @@ import { ThumbsDown, ThumbsUp } from "lucide-react"; import React from "react"; import { Link } from "react-router-dom"; import { ContentWithSnippets } from "./ContentWithSnippets"; +import placeholderProfile from "@/assets/placeholder_profile.png"; interface AnswerItemProps { answer: AnswerDetails; @@ -21,7 +22,7 @@ export const AnswerItem: React.FC = ({ const { token } = useAuthStore(); return ( - +
@@ -61,8 +62,7 @@ export const AnswerItem: React.FC = ({ > {"Profile { + refetch().then(() => { + setOptimisticBookmarking(null); + }); + }, + onError: () => { + setOptimisticBookmarking(null); + }, + }); + const { mutateAsync: removeBookmark } = useRemoveQuestionBookmark({ + onSuccess: () => { + refetch().then(() => { + setOptimisticBookmarking(null); + }); + }, + onError: () => { + setOptimisticBookmarking(null); + }, + }); + + const bookmarked = optimisticBookmarking ?? data?.data?.bookmarked; + + return ( + + ); +} diff --git a/frontend/src/components/CodeSnippet.tsx b/frontend/src/components/CodeSnippet.tsx index b59b6162..f785c3ae 100644 --- a/frontend/src/components/CodeSnippet.tsx +++ b/frontend/src/components/CodeSnippet.tsx @@ -25,7 +25,6 @@ const languageUserFriendlyName = { export const CodeSnippet: React.FC = ({ code, language }) => { const executeCode = useExecuteCode(); - const handleExecute = (e: React.MouseEvent) => { e.preventDefault(); const execution: CodeExecution = { @@ -81,7 +80,7 @@ export const CodeSnippet: React.FC = ({ code, language }) => { {executeCode.isSuccess && (
-

Output:

+

Output (in {executeCode.data.data.executionTime}s):

{executeCode.data.data.output}
)} diff --git a/frontend/src/components/ContentWithSnippets.tsx b/frontend/src/components/ContentWithSnippets.tsx index 88d1bd70..29023053 100644 --- a/frontend/src/components/ContentWithSnippets.tsx +++ b/frontend/src/components/ContentWithSnippets.tsx @@ -1,6 +1,7 @@ import React from "react"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { Components } from "react-markdown"; import { CodeSnippet } from "./CodeSnippet"; +import CustomAnchor from "./CustomAnchor"; interface ContentWithSnippetsProps { content: string; @@ -11,7 +12,11 @@ export const ContentWithSnippets: React.FC = ({ }) => { const parseContent = (text: string) => { const codeBlockRegex = /```(\w+(?:-exec)?)\n([\s\S]*?)```/g; - const parts = []; + const parts: { + type: "text" | "code"; + content: string; + language?: string; + }[] = []; let lastIndex = 0; let match; @@ -27,18 +32,24 @@ export const ContentWithSnippets: React.FC = ({ if (lang.endsWith("-exec")) { parts.push({ type: "code", + content: code.trim(), language: lang.replace("-exec", ""), - code: code.trim(), }); } else { - parts.push({ type: "text", content: match[0] }); + parts.push({ + type: "text", + content: match[0], + }); } lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { - parts.push({ type: "text", content: text.slice(lastIndex) }); + parts.push({ + type: "text", + content: text.slice(lastIndex), + }); } return parts; @@ -48,10 +59,25 @@ export const ContentWithSnippets: React.FC = ({ return parseContent(content).map((part, index) => { if (part.type === "code" && part.language) { return ( - + ); } - return {part.content}; + return ( + + {part.content} + + ); }); }, [content]); diff --git a/frontend/src/components/CustomAnchor.tsx b/frontend/src/components/CustomAnchor.tsx new file mode 100644 index 00000000..151b2a6a --- /dev/null +++ b/frontend/src/components/CustomAnchor.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; + +interface CustomAnchorProps + extends React.AnchorHTMLAttributes { + href?: string; + children?: React.ReactNode; // Make children optional +} + +const CustomAnchor: React.FC = ({ + href, + children, + ...rest +}) => { + const navigate = useNavigate(); + + // Return a plain span if href is not provided + if (!href) return {children}; + + // Extract tag or question ID from the href + const tagMatch = href.match(/^#tag-(\d+)$/); + const questionMatch = href.match(/^#q-(\d+)$/); + + // Handle click event for navigation + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (tagMatch) { + const tagId = tagMatch[1]; + navigate(`/tag/${tagId}`); // Navigate to the tag page + } else if (questionMatch) { + const questionId = questionMatch[1]; + navigate(`/question/${questionId}`); // Navigate to the question page + } + }; + + return ( + + {children} + + ); +}; + +export default CustomAnchor; diff --git a/frontend/src/components/ExerciseCard.tsx b/frontend/src/components/ExerciseCard.tsx index 221a8db6..cae446c5 100644 --- a/frontend/src/components/ExerciseCard.tsx +++ b/frontend/src/components/ExerciseCard.tsx @@ -20,7 +20,7 @@ export const ExerciseCard: React.FC = ({ link, }) => { return ( - +

{title} @@ -28,7 +28,7 @@ export const ExerciseCard: React.FC = ({

{description}

-
+
Difficulty: {difficulty} diff --git a/frontend/src/components/HighlightedQuestionCard.tsx b/frontend/src/components/HighlightedQuestionCard.tsx index 3d9214e8..41465561 100644 --- a/frontend/src/components/HighlightedQuestionCard.tsx +++ b/frontend/src/components/HighlightedQuestionCard.tsx @@ -4,6 +4,7 @@ import { QuestionSummary } from "@/services/api/programmingForumSchemas"; import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react"; import React from "react"; import { Link } from "react-router-dom"; +import placeholderProfile from "@/assets/placeholder_profile.png"; function capitalizeString(difficulty: string): React.ReactNode { return difficulty.charAt(0).toUpperCase() + difficulty.slice(1); @@ -12,9 +13,9 @@ export const HighlightedQuestionCard: React.FC> = ({ id, title, content, - likeCount, + upvoteCount, difficulty, - commentCount, + answerCount, author, }) => { return ( @@ -29,11 +30,11 @@ export const HighlightedQuestionCard: React.FC> = ({
- {likeCount} votes + {upvoteCount} votes
- {commentCount} answers + {answerCount} answers
{difficulty && (
@@ -46,7 +47,7 @@ export const HighlightedQuestionCard: React.FC> = ({ {author && ( {author.name} diff --git a/frontend/src/components/HighlightedQuestionsBox.tsx b/frontend/src/components/HighlightedQuestionsBox.tsx index e0dff917..b5642878 100644 --- a/frontend/src/components/HighlightedQuestionsBox.tsx +++ b/frontend/src/components/HighlightedQuestionsBox.tsx @@ -17,8 +17,8 @@ export const HighlightedQuestionsBox: React.FC<{ id={question.id} title={question.title} content={question.content} - likeCount={question.likeCount} - commentCount={question.commentCount} + upvoteCount={question.upvoteCount} + answerCount={question.answerCount} author={question.author} /> ))} diff --git a/frontend/src/components/QuestionCard.tsx b/frontend/src/components/QuestionCard.tsx index 6e674181..1fd2ea4e 100644 --- a/frontend/src/components/QuestionCard.tsx +++ b/frontend/src/components/QuestionCard.tsx @@ -4,6 +4,8 @@ import { DifficultyLevel } from "@/services/api/programmingForumSchemas"; import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react"; import React from "react"; import { Link } from "react-router-dom"; +import placeholderProfile from "@/assets/placeholder_profile.png"; + interface QuestionCardProps { id: number; @@ -27,7 +29,7 @@ export const QuestionCard = React.forwardRef( ({ id, title, content, votes, answerCount, author, difficulty }, ref) => { return (
@@ -57,7 +59,8 @@ export const QuestionCard = React.forwardRef( {author && ( {author.name} @@ -65,7 +68,7 @@ export const QuestionCard = React.forwardRef( )} Go to question diff --git a/frontend/src/components/TagCard.tsx b/frontend/src/components/TagCard.tsx index da470d9c..6e7682a8 100644 --- a/frontend/src/components/TagCard.tsx +++ b/frontend/src/components/TagCard.tsx @@ -12,7 +12,7 @@ export const TagCard = React.forwardRef( ({ tag }, ref) => { return (
diff --git a/frontend/src/components/TagFollowButton.tsx b/frontend/src/components/TagFollowButton.tsx new file mode 100644 index 00000000..632e6b9b --- /dev/null +++ b/frontend/src/components/TagFollowButton.tsx @@ -0,0 +1,83 @@ +import { + useFollowTag, + useGetTagDetails, + useUnfollowTag, +} from "@/services/api/programmingForumComponents"; +import { useState } from "react"; +import { Button } from "./ui/button"; + +export default function TagFollowButton({ + tag, +}: { + tag: { tagId?: string; following?: boolean }; +}) { + const { isLoading, data, error, refetch } = useGetTagDetails( + { + pathParams: { + tagId: tag.tagId!, + }, + }, + { + enabled: typeof tag.following !== "boolean", + }, + ); + + const [optimisticFollowing, setOptimisticFollowing] = useState( + null as boolean | null, + ); + + const { mutateAsync: follow } = useFollowTag({ + onSuccess: () => { + refetch().then(() => { + setOptimisticFollowing(null); + }); + }, + onError: () => { + setOptimisticFollowing(null); + }, + }); + const { mutateAsync: unfollow } = useUnfollowTag({ + onSuccess: () => { + refetch().then(() => { + setOptimisticFollowing(null); + }); + }, + onError: () => { + setOptimisticFollowing(null); + }, + }); + + const following = optimisticFollowing ?? data?.data?.following; + + return ( + + ); +} diff --git a/frontend/src/routes/profile.tsx b/frontend/src/routes/profile.tsx index 13ef94f6..1eb53a9f 100644 --- a/frontend/src/routes/profile.tsx +++ b/frontend/src/routes/profile.tsx @@ -19,6 +19,7 @@ import { Link, useParams } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; +import placeholderProfile from "@/assets/placeholder_profile.png"; export default function Profile() { const { userId = "" } = useParams<{ userId: string }>(); @@ -80,7 +81,7 @@ export default function Profile() {
@@ -219,8 +220,8 @@ export default function Profile() { id={question.id} title={question.title} content={question.content ?? ""} - votes={question.likeCount} - answerCount={question.commentCount} + votes={question.upvoteCount} + answerCount={question.answerCount} author={question.author} /> ))} diff --git a/frontend/src/routes/question.test.tsx b/frontend/src/routes/question.test.tsx index c491bfc1..f07d8fc8 100644 --- a/frontend/src/routes/question.test.tsx +++ b/frontend/src/routes/question.test.tsx @@ -1,4 +1,4 @@ -import { useGetQuestionDetails } from "@/services/api/programmingForumComponents"; +import { useGetQuestionDetails, useSearchTags } from "@/services/api/programmingForumComponents"; import { QuestionDetails } from "@/services/api/programmingForumSchemas"; import useAuthStore from "@/services/auth"; import { testAccessibility } from "@/utils/test-accessibility"; @@ -17,7 +17,6 @@ import { DifficultyBar } from "@/components/DifficultyBar"; - const mockQuestionData = vi.hoisted( () => ({ @@ -40,6 +39,7 @@ const mockQuestionData = vi.hoisted( createdAt: "2023-01-01T00:00:00Z", updatedAt: "2023-01-01T00:00:00Z", dislikeCount: 0, + difficulty: "EASY", bookmarked: false, selfVoted: 1, selfDifficultyVote: "EASY", @@ -55,6 +55,12 @@ vi.mock("@/services/api/programmingForumComponents", () => ({ useDeleteQuestion: vi.fn(() => ({ mutateAsync: vi.fn(), })), + useBookmarkQuestion: vi.fn(() => ({ + mutateAsync: vi.fn(), + })), + useRemoveQuestionBookmark: vi.fn(() => ({ + mutateAsync: vi.fn(), + })), useVoteQuestion: vi.fn(() => ({ mutateAsync: vi.fn(), })), @@ -83,6 +89,16 @@ vi.mock("@/services/api/programmingForumComponents", () => ({ }, }), })), + useSearchTags: vi.fn(() => ({ + data: { data: { items: [{ tagId: "1", name: "Tag1" }, { tagId: "2", name: "Tag2" }] } }, + isLoading: false, + })), + useUpdateQuestion: vi.fn(() => ({ + mutateAsync: vi.fn().mockResolvedValue({ + data: { success: true }, + }), + isPending: false, + })), })); vi.mock("@/services/exercism", () => ({ @@ -110,9 +126,13 @@ describe("QuestionPage", () => { isLoading: false, error: null, }); + (useSearchTags as Mock).mockReturnValue({ + data: { data: { items: [{ tagId: "1", name: "Tag1" }, { tagId: "2", name: "Tag2" }] } }, + isLoading: false, + }); vi.mocked(useAuthStore).mockReturnValue({ - selfProfile: null, - token: null, + selfProfile: { id: mockQuestionData.author.id }, // Ensure the user matches the question's author + token: "mock-token", }); }); @@ -196,6 +216,22 @@ describe("QuestionPage", () => { expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); }); + it("renders bookmark button", () => { + vi.mocked(useAuthStore).mockReturnValue({ + selfProfile: { id: 1}, + token: "mock-token", + }); + render( + + + } /> + + , + ); + + expect(screen.getByRole("button", { name: /bookmark/i })).toBeInTheDocument(); + }); + it("updates difficulty counts when voting", async () => { // Mock the auth store with a logged-in user vi.mocked(useAuthStore).mockReturnValue({ diff --git a/frontend/src/routes/question.tsx b/frontend/src/routes/question.tsx index 5d2f7c26..888ea944 100644 --- a/frontend/src/routes/question.tsx +++ b/frontend/src/routes/question.tsx @@ -6,20 +6,29 @@ import { DifficultyBar } from "@/components/DifficultyBar"; import ErrorAlert from "@/components/ErrorAlert"; import { ExerciseCard } from "@/components/ExerciseCard"; import FollowButton from "@/components/FollowButton"; +import BookmarkButton from "@/components/BookmarkButton"; import { FullscreenLoading } from "@/components/FullscreenLoading"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/use-toast"; +import { TagDetails } from "@/services/api/programmingForumSchemas"; +import placeholderProfile from "@/assets/placeholder_profile.png"; + import { useDeleteQuestion as useDeleteQuestionById, useDownvoteQuestion, useGetQuestionDetails, useUpvoteQuestion, + useUpdateQuestion, + useSearchTags, } from "@/services/api/programmingForumComponents"; +import { MultiSelect } from "@/components/multi-select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import useAuthStore from "@/services/auth"; import { convertTagToTrack, useExercismSearch } from "@/services/exercism"; import { Flag, MessageSquare, ThumbsDown, ThumbsUp, Trash } from "lucide-react"; -import { useState } from "react"; +import { useEffect,useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; export default function QuestionPage() { @@ -84,15 +93,72 @@ export default function QuestionPage() { }, ); + const { data: tagSearchData } = useSearchTags( + { queryParams: { q: "", pageSize: 1000 } }, + { enabled: true }, + ); + + useEffect(() => { + if (tagSearchData?.data) { + const tagsData = (tagSearchData.data as { items: TagDetails[] }).items; + setAvailableTags(tagsData); + } + }, [tagSearchData]); + + + + const question = data! || {}; + const [isEditing, setIsEditing] = useState(false); // To toggle edit mode + const [isPreviewMode, setIsPreviewMode] = useState(false); // Preview toggle for description + + const titleRef = useRef(null); + const contentRef = useRef(null); + + const [tags, setTags] = useState(question.tags?.map((tag) => Number(tag.id)) || []); // Tag IDs state + const [availableTags, setAvailableTags] = useState<{ tagId: string; name: string }[]>([]); // Available tags + + const { mutateAsync: updateQuestion, isPending } = useUpdateQuestion({ + onSuccess: () => { + refetch(); + setIsEditing(false); + }, + }); + + + const saveChanges = async () => { + try { + await updateQuestion({ + pathParams: { questionId: question.id }, + body: { + title: titleRef.current?.value || question.title, + content: contentRef.current?.value || question.content, + tags: tags, + }, + }); + toast({ + variant: "default", + title: "Changes saved", + description: "The question has been updated successfully.", + }); + setIsEditing(false); + } catch (err) {console.error( + "Failed to save changes", + err + ); + toast({ + variant: "destructive", + title: "Failed to save changes", + description: "An error occurred while updating the question.", + }); + } + }; + if (isLoading) { return ; } if (error) { return ; } - - const question = data! || {}; - if (!question) { return (
+ {isEditing ? ( + + ) : (

{question.title}

+ )} +
)} + {!!token && ( + )}
@@ -148,8 +226,7 @@ export default function QuestionPage() { className="flex items-center gap-4" > {"Profile - {question.tags.map((s) => ( - - {s.name} - - ))} + {isEditing ? ( + ({ + value: String(tag.tagId), + label: tag.name || "Loading...", + }))} + value={tags.map((tag) => String(tag))} + onValueChange={(selectedIds) =>{ + const selectedTags = selectedIds.map((id) => Number(id)); // Convert back to numbers + setTags(selectedTags); + }} + placeholder="Select Tags" + /> + ) : ( +
+ {question.tags.map((tag) => ( + {tag.name} + ))} +
+ )} +
Asked: {new Date(question.createdAt).toLocaleDateString()} @@ -220,7 +313,46 @@ export default function QuestionPage() {
{/* Question Content */} - + {isEditing ? ( +
+
+ + +
+ {isPreviewMode ? ( +
+ +
+ ) : ( +