diff --git a/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java b/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java new file mode 100644 index 00000000..c419ecf8 --- /dev/null +++ b/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java @@ -0,0 +1,144 @@ +package cat.udl.eps.softarch.demo.controller; + +import cat.udl.eps.softarch.demo.domain.Content; +import cat.udl.eps.softarch.demo.domain.Tag; +import cat.udl.eps.softarch.demo.repository.ContentRepository; +import cat.udl.eps.softarch.demo.repository.TagRepository; +import jakarta.transaction.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@RestController +public class ContentTagController { + + private final ContentRepository contentRepository; + private final TagRepository tagRepository; + + public ContentTagController(ContentRepository contentRepository, TagRepository tagRepository) { + this.contentRepository = contentRepository; + this.tagRepository = tagRepository; + } + + @PutMapping(value = "/contents/{contentId}/tags", consumes = "text/uri-list") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity assignTags(@PathVariable Long contentId, @RequestBody String uriList) { + List tagIds = parseTagIds(uriList); + + Content content = contentRepository.findByContentIdWithTagsForUpdate(contentId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + if (content.getTags() == null) { + content.setTags(new ArrayList<>()); + } + + for (Long tagId : tagIds) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!content.getTags().contains(tag)) { + content.getTags().add(tag); + } + } + + contentRepository.save(content); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/contents/{contentId}/tags/{tagId}") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity removeTag(@PathVariable Long contentId, @PathVariable Long tagId) { + Content content = contentRepository.findByContentIdWithTagsForUpdate(contentId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + if (content.getTags() != null) { + content.getTags().removeIf(tag -> tagId.equals(tag.getId())); + contentRepository.save(content); + } + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/tags/{tagId}/delete") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity deleteTag(@PathVariable Long tagId) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + List taggedContents = contentRepository.findByTags_Id(tagId); + for (Content content : taggedContents) { + if (content.getTags() != null) { + content.getTags().removeIf(existingTag -> tagId.equals(existingTag.getId())); + } + } + contentRepository.saveAll(taggedContents); + tagRepository.delete(tag); + + return ResponseEntity.noContent().build(); + } + + @GetMapping({"/tags/{tagId}/available-contents", "/tags/{tagId}/avaiable-contents"}) + public ResponseEntity> getAvailableContents(@PathVariable Long tagId) { + tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + return ResponseEntity.ok(contentRepository.findAvailableForTagId(tagId)); + } + + private List parseTagIds(String uriList) { + if (uriList == null || uriList.trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + List tagIds = Arrays.stream(uriList.split("\\R")) + .map(String::trim) + .filter(line -> !line.isEmpty()) + .filter(line -> !line.startsWith("#")) + .map(this::parseTagId) + .toList(); + + if (tagIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + return tagIds; + } + + private Long parseTagId(String tagUri) { + try { + URI uri = URI.create(tagUri); + String path = uri.getPath(); + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Tag URI has no path"); + } + + String[] segments = Arrays.stream(path.split("/")) + .filter(segment -> !segment.isBlank()) + .toArray(String[]::new); + if (segments.length < 2 || !"tags".equals(segments[segments.length - 2])) { + throw new IllegalArgumentException("Tag URI must point to /tags/{id}"); + } + + String id = segments[segments.length - 1]; + return Long.valueOf(id); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Malformed tag URI", ex); + } + } +} diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java index 2fa1b24a..edde7ada 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java @@ -4,16 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIdentityReference; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; +import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; @@ -37,10 +28,17 @@ public class Content { @Column(nullable = false) private Project project;*/ - /*@ManyToOne - @JsonIdentityReference(alwaysAsId = true) - @Column(nullable = false) - private User user;*/ + @ManyToMany + @JoinTable( + name = "content_tags", + joinColumns = @JoinColumn(name = "content_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private List tags; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; @NotBlank(message = "Name cannot be empty") @Column(unique = true, nullable = false) @@ -53,13 +51,10 @@ public class Content { @Column(length = 100) private String description; - @Column(nullable = false, updatable = false) private ZonedDateTime createdAt; - @Column(nullable = false, updatable = false) private ZonedDateTime modifiedAt; @Enumerated(EnumType.STRING) @Column(nullable = false) private Visibility visibility; - } \ No newline at end of file diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java index 871351ab..7dea84fc 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java @@ -1,5 +1,6 @@ package cat.udl.eps.softarch.demo.domain; +import com.fasterxml.jackson.annotation.JsonIdentityReference; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -24,7 +25,4 @@ public class Tag extends UriEntity { private ZonedDateTime created; private ZonedDateTime modified; - - //@ManyToMany(mappedBy = "tags") - //private Set contentSet = new HashSet<>(); } diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java index da77a057..dc2db0fb 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java @@ -2,8 +2,13 @@ import cat.udl.eps.softarch.demo.domain.Content; //import cat.udl.eps.softarch.demo.domain.Visibility; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; import org.springframework.data.rest.core.annotation.RepositoryRestResource; import java.util.Optional; @@ -19,8 +24,23 @@ public interface ContentRepository extends CrudRepository findAll(); + List findByTags_Id(Long tagId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = "tags") + @Query("select c from Content c where c.contentId = :contentId") + Optional findByContentIdWithTagsForUpdate(@Param("contentId") Long contentId); + + @Query(""" + select distinct c from Content c + where c.contentId not in ( + select tagged.contentId from Content tagged join tagged.tags t + where t.id = :tagId + ) + """) + List findAvailableForTagId(@Param("tagId") Long tagId); //List findByProjectId(Long projectId); //List findByProjectIdAndVisibility(Long projectId, Visibility visibility); -} \ No newline at end of file +} diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java index 38a0a75c..2e72a960 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java @@ -12,4 +12,5 @@ public interface TagRepository extends CrudRepository { boolean existsById(Long id); boolean existsByName(String name); Optional findByName(String name); + Optional findById(Long id); } diff --git a/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java b/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java new file mode 100644 index 00000000..921df7c4 --- /dev/null +++ b/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java @@ -0,0 +1,108 @@ +package cat.udl.eps.softarch.demo.steps; + +import cat.udl.eps.softarch.demo.domain.Tag; +import cat.udl.eps.softarch.demo.domain.Content; +import cat.udl.eps.softarch.demo.domain.User; +import cat.udl.eps.softarch.demo.domain.Visibility; +import cat.udl.eps.softarch.demo.repository.TagRepository; +import cat.udl.eps.softarch.demo.repository.ContentRepository; +import cat.udl.eps.softarch.demo.repository.UserRepository; + +import io.cucumber.java.en.*; +import jakarta.transaction.Transactional; + +import java.time.ZonedDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +public class TagOwnershipStepDefs { + + private final StepDefs stepDefs; + private final TagRepository tagRepository; + private final ContentRepository contentRepository; + private final UserRepository userRepository; + + private Tag tag; + private Content content; + + public TagOwnershipStepDefs(StepDefs stepDefs, + TagRepository tagRepository, + ContentRepository contentRepository, + UserRepository userRepository) { + this.stepDefs = stepDefs; + this.tagRepository = tagRepository; + this.contentRepository = contentRepository; + this.userRepository = userRepository; + } + + @Given("there is a content {string} created by {string}") + public void thereIsAContentCreatedBy(String contentName, String username) { + + User user = userRepository.findById(username) + .orElseThrow(() -> new RuntimeException("User not found")); + + content = new Content(); + content.setName(contentName); + content.setUser(user); + content.setCreatedAt(ZonedDateTime.now()); + content.setModifiedAt(ZonedDateTime.now()); + content.setVisibility(Visibility.PUBLIC); + + content = contentRepository.save(content); + } + + @When("I assign the tag {string} to content {string}") + public void iAssignTagToContent(String tagName, String contentName) throws Exception { + + Tag tagEntity = tagRepository.findByName(tagName) + .orElseThrow(() -> new RuntimeException("Tag not found")); + + Content contentEntity = contentRepository.findById(content.getContentId()) + .orElseThrow(() -> new RuntimeException("Content not found")); + + String authenticatedUsername = "creator1"; + + if (!contentEntity.getUser().getUsername().equals(authenticatedUsername)) { + stepDefs.result = stepDefs.mockMvc.perform( + post("/contents/" + contentEntity.getContentId() + "/tags") + .contentType("text/uri-list") + .content("/tags/" + tagEntity.getId()) + .with(user(authenticatedUsername)) + ).andDo(print()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden()); + return; + } + + stepDefs.result = stepDefs.mockMvc.perform( + post("/contents/" + contentEntity.getContentId() + "/tags") + .contentType("text/uri-list") + .content("/tags/" + tagEntity.getId()) + .with(user(authenticatedUsername)) + ).andDo(print()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNoContent()); + } + + @Then("The response status is {int}") + public void theResponseStatusIs(int status) throws Exception { + stepDefs.result.andExpect( + org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().is(status) + ); + } + + @Then("content {string} should contain tag {string}") + @Transactional + public void contentShouldContainTag(String contentName, String tagName) { + + Content updatedContent = contentRepository.findById(content.getContentId()) + .orElseThrow(() -> new RuntimeException("Content not found")); + + boolean exists = updatedContent.getTags() + .stream() + .anyMatch(t -> t.getName().equals(tagName)); + + assertTrue(exists, "Tag not assigned correctly to content"); + } +} \ No newline at end of file