diff --git a/skill-tree/src/main/java/com/RDS/skilltree/apis/TaskSkillApi.java b/skill-tree/src/main/java/com/RDS/skilltree/apis/TaskSkillApi.java new file mode 100644 index 00000000..7aa5120d --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/apis/TaskSkillApi.java @@ -0,0 +1,41 @@ +package com.RDS.skilltree.apis; + +import com.RDS.skilltree.annotations.AuthorizedRoles; +import com.RDS.skilltree.enums.UserRoleEnum; +import com.RDS.skilltree.models.JwtUser; +import com.RDS.skilltree.services.TaskSkillService; +import com.RDS.skilltree.viewmodels.CreateTaskSkillViewModel; +import com.RDS.skilltree.viewmodels.TaskSkillRequestViewModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("v1/tasks") +@Validated +public class TaskSkillApi { + + private final TaskSkillService taskSkillService; + + @AuthorizedRoles({UserRoleEnum.SUPERUSER}) + @PostMapping("/{taskId}/skills") + public ResponseEntity createTaskSkills( + @PathVariable @Pattern(regexp = "^[A-Za-z0-9]+$", message = "Task ID must be valid") + String taskId, + @Valid @RequestBody TaskSkillRequestViewModel request) { + JwtUser currentUser = + (JwtUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + String createdBy = currentUser.getRdsUserId(); + CreateTaskSkillViewModel response = + taskSkillService.createTaskSkills(taskId, request.getSkillIds(), createdBy); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/exceptions/GlobalExceptionHandler.java b/skill-tree/src/main/java/com/RDS/skilltree/exceptions/GlobalExceptionHandler.java index e5abe97c..40541670 100644 --- a/skill-tree/src/main/java/com/RDS/skilltree/exceptions/GlobalExceptionHandler.java +++ b/skill-tree/src/main/java/com/RDS/skilltree/exceptions/GlobalExceptionHandler.java @@ -126,6 +126,14 @@ public ResponseEntity handleSkillNotFoundException( return new ResponseEntity<>(new GenericResponse<>(null, ex.getMessage()), HttpStatus.NOT_FOUND); } + @ExceptionHandler(TaskSkillAssociationAlreadyExistsException.class) + public ResponseEntity> handleTaskSkillAssociationAlreadyExistsException( + TaskSkillAssociationAlreadyExistsException ex) { + log.error("Duplicate task-skill association: {}", ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new GenericResponse<>(null, ex.getMessage())); + } + @ExceptionHandler(EndorsementNotFoundException.class) public ResponseEntity handleEndorsementNotException( EndorsementNotFoundException ex, WebRequest request) { diff --git a/skill-tree/src/main/java/com/RDS/skilltree/exceptions/TaskSkillAssociationAlreadyExistsException.java b/skill-tree/src/main/java/com/RDS/skilltree/exceptions/TaskSkillAssociationAlreadyExistsException.java new file mode 100644 index 00000000..661d2b2f --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/exceptions/TaskSkillAssociationAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.RDS.skilltree.exceptions; + +public class TaskSkillAssociationAlreadyExistsException extends RuntimeException { + public TaskSkillAssociationAlreadyExistsException(String message, Throwable cause) { + super(message, cause); + } + + public TaskSkillAssociationAlreadyExistsException(String message) { + super(message); + } +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkill.java b/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkill.java new file mode 100644 index 00000000..3b925dfa --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkill.java @@ -0,0 +1,47 @@ +package com.RDS.skilltree.models; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.*; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; + +@Entity +@Table(name = "task_skills") +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TaskSkill { + + @EmbeddedId private TaskSkillId id; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "is_deleted", nullable = false) + @Builder.Default + private boolean isDeleted = false; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @CreatedBy + @Column(name = "created_by", nullable = false, updatable = false) + private String createdBy; + + @LastModifiedBy + @Column(name = "updated_by") + private String updatedBy; + + // Relationship to Skill entity for richer detail access. + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("skillId") + @JoinColumn(name = "skill_id", insertable = false, updatable = false) + private Skill skill; +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkillId.java b/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkillId.java new file mode 100644 index 00000000..1f850152 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/models/TaskSkillId.java @@ -0,0 +1,24 @@ +package com.RDS.skilltree.models; + +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotNull; +import java.io.Serial; +import java.io.Serializable; +import lombok.*; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode +public class TaskSkillId implements Serializable { + @Serial private static final long serialVersionUID = 1L; + + @NotNull(message = "Task ID cannot be null") + private String taskId; + + @NotNull(message = "Skill ID cannot be null") + private Integer skillId; +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/repositories/TaskSkillRepository.java b/skill-tree/src/main/java/com/RDS/skilltree/repositories/TaskSkillRepository.java new file mode 100644 index 00000000..1e724d60 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/repositories/TaskSkillRepository.java @@ -0,0 +1,20 @@ +package com.RDS.skilltree.repositories; + +import com.RDS.skilltree.models.TaskSkill; +import com.RDS.skilltree.models.TaskSkillId; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TaskSkillRepository extends JpaRepository { + /** + * Find skill IDs that already have associations with the given task ID + */ + @Query( + "SELECT ts.id.skillId FROM TaskSkill ts WHERE ts.id.taskId = :taskId AND ts.id.skillId IN :skillIds") + Set findSkillIdsByTaskIdAndSkillIdIn( + @Param("taskId") String taskId, @Param("skillIds") Set skillIds); +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillService.java b/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillService.java new file mode 100644 index 00000000..06984232 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillService.java @@ -0,0 +1,21 @@ +package com.RDS.skilltree.services; + +import com.RDS.skilltree.exceptions.SkillNotFoundException; +import com.RDS.skilltree.exceptions.TaskSkillAssociationAlreadyExistsException; +import com.RDS.skilltree.viewmodels.CreateTaskSkillViewModel; +import java.util.List; + +public interface TaskSkillService { + /** + * Creates associations between a task and multiple skills. + * + * @param taskId The unique identifier of the task. + * @param skillIds List of skill identifiers to associate with the task. + * @param createdBy The identifier of the user creating these associations. + * @return A response view model indicating success. + * @throws TaskSkillAssociationAlreadyExistsException if an association already exists. + * @throws SkillNotFoundException if any skill does not exist. + */ + CreateTaskSkillViewModel createTaskSkills( + String taskId, List skillIds, String createdBy); +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillServiceImplementation.java b/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillServiceImplementation.java new file mode 100644 index 00000000..092a24b9 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/services/TaskSkillServiceImplementation.java @@ -0,0 +1,82 @@ +package com.RDS.skilltree.services; + +import com.RDS.skilltree.exceptions.SkillNotFoundException; +import com.RDS.skilltree.exceptions.TaskSkillAssociationAlreadyExistsException; +import com.RDS.skilltree.models.Skill; +import com.RDS.skilltree.models.TaskSkill; +import com.RDS.skilltree.models.TaskSkillId; +import com.RDS.skilltree.repositories.SkillRepository; +import com.RDS.skilltree.repositories.TaskSkillRepository; +import com.RDS.skilltree.viewmodels.CreateTaskSkillViewModel; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskSkillServiceImplementation implements TaskSkillService { + + private final TaskSkillRepository taskSkillRepository; + private final SkillRepository skillRepository; + + public TaskSkillServiceImplementation( + TaskSkillRepository taskSkillRepository, SkillRepository skillRepository) { + this.taskSkillRepository = taskSkillRepository; + this.skillRepository = skillRepository; + } + + @Override + @Transactional + public CreateTaskSkillViewModel createTaskSkills( + String taskId, List skillIds, String createdBy) { + // Remove duplicate skill IDs + Set uniqueSkillIds = new HashSet<>(skillIds); + + // FIRST: Check if any TaskSkill associations already exist (batch check) + Set existingSkillIds = + taskSkillRepository.findSkillIdsByTaskIdAndSkillIdIn(taskId, uniqueSkillIds); + if (!existingSkillIds.isEmpty()) { + throw new TaskSkillAssociationAlreadyExistsException( + "Task-Skill associations already exist for task " + + taskId + + " and skills: " + + existingSkillIds); + } + + // SECOND: Load all skills at once and verify they exist + List skills = skillRepository.findAllById(uniqueSkillIds); + + // Check for missing skills + if (skills.size() != uniqueSkillIds.size()) { + Set foundSkillIds = skills.stream().map(Skill::getId).collect(Collectors.toSet()); + Set missingIds = new HashSet<>(uniqueSkillIds); + missingIds.removeAll(foundSkillIds); + throw new SkillNotFoundException("Skill not found for skillId(s): " + missingIds); + } + + // Create a map for quick lookups + Map skillsMap = skills.stream().collect(Collectors.toMap(Skill::getId, s -> s)); + + // Create and save TaskSkill entities + LocalDateTime now = LocalDateTime.now(); + List taskSkillsToSave = + uniqueSkillIds.stream() + .map( + skillId -> + TaskSkill.builder() + .id(new TaskSkillId(taskId, skillId)) + .skill(skillsMap.get(skillId)) // Set the actual Skill entity reference + .createdAt(now) + .createdBy(createdBy) + .build()) + .collect(Collectors.toList()); + + taskSkillRepository.saveAll(taskSkillsToSave); + + return new CreateTaskSkillViewModel("Skills are linked to task successfully!"); + } +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/CreateTaskSkillViewModel.java b/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/CreateTaskSkillViewModel.java new file mode 100644 index 00000000..ce762c19 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/CreateTaskSkillViewModel.java @@ -0,0 +1,12 @@ +package com.RDS.skilltree.viewmodels; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateTaskSkillViewModel { + private String message; +} diff --git a/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/TaskSkillRequestViewModel.java b/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/TaskSkillRequestViewModel.java new file mode 100644 index 00000000..8673d794 --- /dev/null +++ b/skill-tree/src/main/java/com/RDS/skilltree/viewmodels/TaskSkillRequestViewModel.java @@ -0,0 +1,15 @@ +package com.RDS.skilltree.viewmodels; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TaskSkillRequestViewModel { + @NotEmpty(message = "Skill IDs list cannot be empty") + private List skillIds; +} diff --git a/skill-tree/src/main/resources/db/migrations/V2__task_skills_association_table.sql b/skill-tree/src/main/resources/db/migrations/V2__task_skills_association_table.sql new file mode 100644 index 00000000..527e3734 --- /dev/null +++ b/skill-tree/src/main/resources/db/migrations/V2__task_skills_association_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE `task_skills` ( + `task_id` varchar(255) NOT NULL, + `skill_id` int NOT NULL, + `created_at` datetime(6) NOT NULL, + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `updated_at` datetime(6) DEFAULT NULL, + `created_by` varchar(255) NOT NULL, + `updated_by` varchar(255) DEFAULT NULL, + PRIMARY KEY (`task_id`, `skill_id`), + CONSTRAINT `fk_task_skills_skill_id` FOREIGN KEY (`skill_id`) REFERENCES `skills` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; \ No newline at end of file