From 25a855ea90dc99aa119d7f0599ab4c68e4f6560b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:25:55 +0700 Subject: [PATCH] refactor(project): adopt feature JPA boundaries --- .../project/controller/ProjectController.java | 97 ++++++ .../ProjectAccessDeniedException.java | 8 + .../exception/ProjectControllerAdvice.java | 21 ++ .../ProjectRuleViolationException.java | 8 + .../model/ProjectInternEligibility.java | 14 + .../project/model/ProjectLeaderChange.java | 7 + .../project/model}/ProjectStatus.java | 2 +- .../project/model/dto/ProjectActorView.java | 4 + .../model/dto/ProjectCreateCommand.java | 11 + .../project/model/dto/ProjectCreateForm.java | 30 ++ .../model/dto/ProjectDashboardSummary.java | 4 + .../project/model/dto/ProjectDetail.java | 14 + .../model/dto/ProjectLeadershipTermView.java | 10 + .../project/model/dto/ProjectMemberForm.java | 6 + .../project/model/dto/ProjectMemberView.java | 12 + .../project/model/dto/ProjectSummary.java | 6 + .../project/model/dto/ProjectTaskContext.java | 18 ++ .../model/dto/ProjectTaskMemberView.java | 4 + .../project/model/entity/ProjectEntity.java | 287 ++++++++++++++++++ .../entity/ProjectLeadershipTermEntity.java | 93 ++++++ .../model/entity/ProjectMembershipEntity.java | 79 +++++ .../project/repository/ProjectRepository.java | 29 ++ .../project/service/ProjectQueryService.java | 196 ++++++++++++ .../project/service/ProjectService.java | 82 +++++ .../projects/domain/EligibleIntern.java | 14 - .../projects/domain/LeadershipTerm.java | 50 --- .../labtimesheet/projects/domain/Project.java | 205 ------------- .../projects/domain/ProjectAccessDenied.java | 8 - .../projects/domain/ProjectMembership.java | 37 --- .../projects/domain/ProjectRuleViolation.java | 8 - .../resources/templates/projects/detail.html | 12 + .../resources/templates/projects/form.html | 19 ++ .../templates/projects/leadership.html | 13 + .../resources/templates/projects/list.html | 20 ++ .../resources/templates/projects/members.html | 13 + .../controller/ProjectControllerTest.java | 142 +++++++++ .../model/entity/ProjectEntityTest.java} | 49 +-- .../ProjectPersistenceStructureTest.java | 31 ++ .../ProjectServiceIntegrationTest.java | 231 ++++++++++++++ 39 files changed, 1549 insertions(+), 345 deletions(-) create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java rename src/main/java/com/lab/labtimesheet/{projects/domain => feature/project/model}/ProjectStatus.java (57%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/Project.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java create mode 100644 src/main/resources/templates/projects/detail.html create mode 100644 src/main/resources/templates/projects/form.html create mode 100644 src/main/resources/templates/projects/leadership.html create mode 100644 src/main/resources/templates/projects/list.html create mode 100644 src/main/resources/templates/projects/members.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java rename src/test/java/com/lab/labtimesheet/{projects/domain/ProjectTest.java => feature/project/model/entity/ProjectEntityTest.java} (70%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java new file mode 100644 index 0000000..16c4758 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -0,0 +1,97 @@ +package com.lab.labtimesheet.feature.project.controller; + +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import jakarta.validation.Valid; +import java.security.Principal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/projects") +public class ProjectController { + + private final ProjectQueryService pages; + private final ProjectService projects; + + public ProjectController(ProjectQueryService pages, ProjectService projects) { + this.pages = pages; + this.projects = projects; + } + + @GetMapping + public String list(Principal principal, Model model) { + model.addAttribute("projects", pages.listVisible(actorId(principal))); + return "projects/list"; + } + + @GetMapping("/new") + public String createForm(Model model) { + model.addAttribute("projectForm", new ProjectCreateForm()); + return "projects/form"; + } + + @PostMapping + public String create( + Principal principal, + @Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm, + BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + return "projects/form"; + } + long projectId = projects.create(actorId(principal), projectForm.toCommand()); + return "redirect:/projects/" + projectId; + } + + @GetMapping("/{projectId}") + public String detail(Principal principal, @PathVariable long projectId, Model model) { + model.addAttribute("project", pages.detail(actorId(principal), projectId)); + return "projects/detail"; + } + + @GetMapping("/{projectId}/members") + public String members(Principal principal, @PathVariable long projectId, Model model) { + long actorId = actorId(principal); + model.addAttribute("project", pages.detail(actorId, projectId)); + model.addAttribute("members", pages.members(actorId, projectId)); + return "projects/members"; + } + + @PostMapping("/{projectId}/members") + public String addMember( + Principal principal, + @PathVariable long projectId, + @Valid @ModelAttribute ProjectMemberForm memberForm) { + projects.addMember(actorId(principal), projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/members"; + } + + @GetMapping("/{projectId}/leadership") + public String leadership(Principal principal, @PathVariable long projectId, Model model) { + long actorId = actorId(principal); + model.addAttribute("project", pages.detail(actorId, projectId)); + model.addAttribute("leadership", pages.leadership(actorId, projectId)); + return "projects/leadership"; + } + + @PostMapping("/{projectId}/leadership") + public String changeLeader( + Principal principal, + @PathVariable long projectId, + @Valid @ModelAttribute ProjectMemberForm memberForm) { + projects.changeLeader(actorId(principal), projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/leadership"; + } + + private long actorId(Principal principal) { + return pages.authenticatedUserId(principal.getName()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java new file mode 100644 index 0000000..65ef0dd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.project.exception; + +public final class ProjectAccessDeniedException extends RuntimeException { + + public ProjectAccessDeniedException() { + super("Project access denied"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java new file mode 100644 index 0000000..f6c381c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java @@ -0,0 +1,21 @@ +package com.lab.labtimesheet.feature.project.exception; + +import com.lab.labtimesheet.feature.project.controller.ProjectController; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +public class ProjectControllerAdvice { + + @ExceptionHandler(ProjectAccessDeniedException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public void accessDenied() { + } + + @ExceptionHandler(ProjectRuleViolationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public void conflict() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java new file mode 100644 index 0000000..86d24d6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.project.exception; + +public final class ProjectRuleViolationException extends RuntimeException { + + public ProjectRuleViolationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java new file mode 100644 index 0000000..63668ed --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.project.model; + +public record ProjectInternEligibility(long userId, boolean eligible) { + + public ProjectInternEligibility { + if (userId <= 0) { + throw new IllegalArgumentException("Intern user ID must be positive"); + } + } + + public boolean isEligible() { + return eligible; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java new file mode 100644 index 0000000..7c1d67d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.project.model; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity; +import java.time.Instant; + +public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) { +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java similarity index 57% rename from src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java rename to src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java index 0e24065..75d9e0d 100644 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.projects.domain; +package com.lab.labtimesheet.feature.project.model; public enum ProjectStatus { PLANNED, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java new file mode 100644 index 0000000..6139322 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectActorView(long userId, String role) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java new file mode 100644 index 0000000..2c2913f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java @@ -0,0 +1,11 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectCreateCommand( + String name, + String description, + LocalDate startDate, + LocalDate endDate, + long initialLeaderUserId) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java new file mode 100644 index 0000000..b95332a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java @@ -0,0 +1,30 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record ProjectCreateForm( + @NotBlank @Size(max = 160) String name, + String description, + @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @NotNull @Positive Long initialLeaderUserId) { + + public ProjectCreateForm() { + this(null, null, null, null, null); + } + + @AssertTrue(message = "End date must not precede start date") + public boolean isDateRangeValid() { + return startDate == null || endDate == null || !endDate.isBefore(startDate); + } + + public ProjectCreateCommand toCommand() { + return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java new file mode 100644 index 0000000..8a970f0 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java new file mode 100644 index 0000000..4af0e54 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectDetail( + long id, + String name, + String description, + String status, + LocalDate startDate, + LocalDate endDate, + String mentorName, + String leaderName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java new file mode 100644 index 0000000..a63fd92 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.Instant; + +public record ProjectLeadershipTermView( + long id, + String leaderName, + Instant startedAt, + Instant endedAt) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java new file mode 100644 index 0000000..13d483c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import jakarta.validation.constraints.Positive; + +public record ProjectMemberForm(@Positive long internUserId) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java new file mode 100644 index 0000000..b799097 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.Instant; + +public record ProjectMemberView( + long membershipId, + long internUserId, + String displayName, + Instant joinedAt, + Instant leftAt, + boolean currentLeader) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java new file mode 100644 index 0000000..93de086 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java new file mode 100644 index 0000000..ffb3570 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java @@ -0,0 +1,18 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; +import java.util.List; + +public record ProjectTaskContext( + long projectId, + long mentorUserId, + String status, + LocalDate startDate, + LocalDate endDate, + Long currentLeaderMembershipId, + List activeMembers) { + + public ProjectTaskContext { + activeMembers = List.copyOf(activeMembers); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java new file mode 100644 index 0000000..3123f1d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectTaskMemberView(long membershipId, long userId, String displayName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java new file mode 100644 index 0000000..eabaefc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java @@ -0,0 +1,287 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; +import com.lab.labtimesheet.feature.project.model.ProjectLeaderChange; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; +import jakarta.persistence.CascadeType; +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.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Entity +@Table(name = "projects") +public class ProjectEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "mentor_user_id", nullable = false) + private long mentorUserId; + + @Column(nullable = false, length = 160) + private String name; + + @Column + private String description; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL) + private List memberships = new ArrayList<>(); + + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL) + private List leadershipTerms = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ProjectStatus status = ProjectStatus.PLANNED; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected ProjectEntity() { + } + + private ProjectEntity( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + Instant createdAt) { + this.mentorUserId = mentorUserId; + this.name = name; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } + + public static ProjectEntity plan( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + ProjectInternEligibility initialLeader, + Instant at) { + if (mentorUserId <= 0) { + throw new IllegalArgumentException("Mentor user ID must be positive"); + } + var normalizedName = requireText(name, "Project name is required"); + Objects.requireNonNull(startDate, "startDate"); + Objects.requireNonNull(endDate, "endDate"); + Objects.requireNonNull(at, "at"); + if (endDate.isBefore(startDate)) { + throw new ProjectRuleViolationException("Project end date must not precede its start date"); + } + requireEligible(initialLeader); + + var project = new ProjectEntity( + mentorUserId, + normalizedName, + normalizeOptionalText(description), + startDate, + endDate, + at); + var membership = project.addEligibleMember(initialLeader, mentorUserId, at); + project.leadershipTerms.add(new ProjectLeadershipTermEntity(project, membership, at, mentorUserId)); + return project; + } + + public ProjectMembershipEntity addMember( + long actorMentorUserId, ProjectInternEligibility intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + if (hasCurrentMember(intern.userId())) { + throw new ProjectRuleViolationException("Intern is already a current Project member"); + } + return addEligibleMember(intern, actorMentorUserId, at); + } + + public ProjectLeaderChange prepareLeaderChange( + long actorMentorUserId, ProjectInternEligibility intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + var replacement = currentMembership(intern.userId()); + var current = currentLeadershipTerm(); + if (current.internUserId() == intern.userId()) { + throw new ProjectRuleViolationException("Selected Intern is already the current Leader"); + } + + var effectiveAt = current.end(at, actorMentorUserId); + updatedAt = effectiveAt; + return new ProjectLeaderChange(replacement, effectiveAt); + } + + public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) { + requireOwner(actorMentorUserId); + Objects.requireNonNull(change, "change"); + if (leadershipTerms.stream().anyMatch(ProjectLeadershipTermEntity::isCurrent)) { + throw new ProjectRuleViolationException("Current Leader must be closed before replacement"); + } + leadershipTerms.add(new ProjectLeadershipTermEntity( + this, change.replacement(), change.effectiveAt(), actorMentorUserId)); + } + + public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { + requireOwner(actorMentorUserId); + Objects.requireNonNull(at, "at"); + if (status != ProjectStatus.PLANNED) { + throw new ProjectRuleViolationException("Only a planned Project can be activated"); + } + if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent) + || leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) { + throw new ProjectRuleViolationException("Project requires a current member and Leader"); + } + if (!allTaskAssigneesAreCurrent) { + throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member"); + } + status = ProjectStatus.ACTIVE; + activatedAt = at; + updatedAt = at; + } + + public Long id() { + return id; + } + + public long mentorUserId() { + return mentorUserId; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public LocalDate startDate() { + return startDate; + } + + public LocalDate endDate() { + return endDate; + } + + public ProjectStatus status() { + return status; + } + + public Instant activatedAt() { + return activatedAt; + } + + public List memberships() { + return List.copyOf(memberships); + } + + public List leadershipTerms() { + return List.copyOf(leadershipTerms); + } + + public void authorizeOwner(long actorMentorUserId) { + requireOwner(actorMentorUserId); + } + + public boolean hasCurrentMember(long internUserId) { + return memberships.stream() + .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); + } + + public boolean hasEverHadMember(long internUserId) { + return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId); + } + + public ProjectMembershipEntity currentLeader() { + return currentMembership(currentLeadershipTerm().internUserId()); + } + + private ProjectMembershipEntity addEligibleMember( + ProjectInternEligibility intern, long addedByUserId, Instant at) { + var membership = new ProjectMembershipEntity(this, intern.userId(), at, addedByUserId); + memberships.add(membership); + updatedAt = at; + return membership; + } + + private ProjectMembershipEntity currentMembership(long internUserId) { + return memberships.stream() + .filter(membership -> membership.internUserId() == internUserId && membership.isCurrent()) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolationException( + "Leader must be a current same-Project member")); + } + + private ProjectLeadershipTermEntity currentLeadershipTerm() { + return leadershipTerms.stream() + .filter(ProjectLeadershipTermEntity::isCurrent) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolationException("Project has no current Leader")); + } + + private void requireOwner(long actorMentorUserId) { + if (mentorUserId != actorMentorUserId) { + throw new ProjectAccessDeniedException(); + } + } + + private void requireMutable() { + if (status == ProjectStatus.COMPLETED) { + throw new ProjectRuleViolationException("Completed Projects are read-only"); + } + } + + private static void requireEligible(ProjectInternEligibility intern) { + Objects.requireNonNull(intern, "intern"); + if (!intern.isEligible()) { + throw new ProjectRuleViolationException("Intern must have an active account and internship"); + } + } + + private static String requireText(String value, String message) { + if (value == null || value.trim().isEmpty()) { + throw new ProjectRuleViolationException(message); + } + return value.trim(); + } + + private static String normalizeOptionalText(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java new file mode 100644 index 0000000..62974e5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java @@ -0,0 +1,93 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "project_leadership_terms") +public class ProjectLeadershipTermEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private ProjectEntity project; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "membership_id", nullable = false) + private ProjectMembershipEntity membership; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "appointed_by_mentor_user_id", nullable = false) + private long appointedByMentorUserId; + + @Column(name = "ended_at") + private Instant endedAt; + + @Column(name = "ended_by_mentor_user_id") + private Long endedByMentorUserId; + + protected ProjectLeadershipTermEntity() { + } + + ProjectLeadershipTermEntity( + ProjectEntity project, + ProjectMembershipEntity membership, + Instant startedAt, + long appointedByMentorUserId) { + this.project = project; + this.membership = membership; + this.startedAt = startedAt; + this.appointedByMentorUserId = appointedByMentorUserId; + } + + public Long id() { + return id; + } + + public long internUserId() { + return membership.internUserId(); + } + + public Instant startedAt() { + return startedAt; + } + + public long appointedByMentorUserId() { + return appointedByMentorUserId; + } + + public Instant endedAt() { + return endedAt; + } + + public Long endedByMentorUserId() { + return endedByMentorUserId; + } + + public boolean isCurrent() { + return endedAt == null; + } + + Instant end(Instant at, long mentorUserId) { + if (!isCurrent() || at.isBefore(startedAt)) { + throw new ProjectRuleViolationException("Leadership term end must follow its start"); + } + endedAt = at.equals(startedAt) ? startedAt.plusNanos(1_000) : at; + endedByMentorUserId = mentorUserId; + return endedAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java new file mode 100644 index 0000000..59f158c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java @@ -0,0 +1,79 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; + +@Entity +@Table(name = "project_memberships") +public class ProjectMembershipEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private ProjectEntity project; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "joined_at", nullable = false) + private Instant joinedAt; + + @Column(name = "added_by_user_id", nullable = false) + private long addedByUserId; + + @Column(name = "left_at") + private Instant leftAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected ProjectMembershipEntity() { + } + + ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) { + this.project = project; + this.internUserId = internUserId; + this.joinedAt = joinedAt; + this.addedByUserId = addedByUserId; + this.updatedAt = joinedAt; + } + + public Long id() { + return id; + } + + public long internUserId() { + return internUserId; + } + + public Instant joinedAt() { + return joinedAt; + } + + public long addedByUserId() { + return addedByUserId; + } + + public Instant leftAt() { + return leftAt; + } + + public boolean isCurrent() { + return leftAt == null; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java new file mode 100644 index 0000000..c0be117 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java @@ -0,0 +1,29 @@ +package com.lab.labtimesheet.feature.project.repository; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProjectRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select project from ProjectEntity project where project.id = :id") + Optional findLockedById(@Param("id") long id); + + List findAllByOrderByUpdatedAtDescIdDesc(); + + List findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId); + + @Query(""" + select distinct project from ProjectEntity project + join project.memberships membership + where membership.internUserId = :internUserId + order by project.updatedAt desc, project.id desc + """) + List findVisibleToIntern(@Param("internUserId") long internUserId); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java new file mode 100644 index 0000000..eadff93 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -0,0 +1,196 @@ +package com.lab.labtimesheet.feature.project.service; + +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.repository.ProjectRepository; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectQueryService { + + private final ProjectRepository projects; + private final AccountService accounts; + + public ProjectQueryService(ProjectRepository projects, AccountService accounts) { + this.projects = projects; + this.accounts = accounts; + } + + @Transactional(readOnly = true) + public long authenticatedUserId(String email) { + return authenticatedActor(email).userId(); + } + + @Transactional(readOnly = true) + public ProjectActorView authenticatedActor(String email) { + try { + var actor = accounts.requireIdentityByEmail(email); + if (!"ACTIVE".equals(actor.status().name())) { + throw new ProjectAccessDeniedException(); + } + return new ProjectActorView(actor.id(), actor.role().name()); + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + @Transactional(readOnly = true) + public List listVisible(long actorUserId) { + var actor = activeActor(actorUserId); + return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList(); + } + + @Transactional(readOnly = true) + public ProjectDetail detail(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + return new ProjectDetail( + project.id(), + project.name(), + project.description(), + project.status().name(), + project.startDate(), + project.endDate(), + displayName(project.mentorUserId()), + displayName(project.currentLeader().internUserId())); + } + + @Transactional(readOnly = true) + public List members(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + var leaderUserId = project.currentLeader().internUserId(); + return project.memberships().stream() + .map(membership -> new ProjectMemberView( + membership.id(), + membership.internUserId(), + displayName(membership.internUserId()), + membership.joinedAt(), + membership.leftAt(), + membership.isCurrent() && membership.internUserId() == leaderUserId)) + .toList(); + } + + @Transactional(readOnly = true) + public List leadership(long actorUserId, long projectId) { + return visibleProject(actorUserId, projectId).leadershipTerms().stream() + .sorted((left, right) -> right.startedAt().compareTo(left.startedAt())) + .map(term -> new ProjectLeadershipTermView( + term.id(), + displayName(term.internUserId()), + term.startedAt(), + term.endedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public ProjectTaskContext taskContext(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + var activeMembers = project.memberships().stream() + .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) + .map(membership -> new ProjectTaskMemberView( + membership.id(), + membership.internUserId(), + displayName(membership.internUserId()))) + .toList(); + var currentLeader = project.currentLeader(); + var currentLeaderMembershipId = activeMembers.stream() + .filter(member -> member.membershipId() == currentLeader.id()) + .map(ProjectTaskMemberView::membershipId) + .findFirst() + .orElse(null); + return new ProjectTaskContext( + project.id(), + project.mentorUserId(), + project.status().name(), + project.startDate(), + project.endDate(), + currentLeaderMembershipId, + activeMembers); + } + + @Transactional(readOnly = true) + public ProjectDashboardSummary dashboardSummary(long actorUserId) { + var actor = activeActor(actorUserId); + var activeProjects = visibleProjects(actor, actorUserId).stream() + .filter(project -> project.status() == ProjectStatus.ACTIVE) + .filter(project -> !"INTERN".equals(actor.role().name()) + || (accounts.isEligibleIntern(actorUserId) && project.hasCurrentMember(actorUserId))) + .toList(); + var distinctActiveMembers = "MENTOR".equals(actor.role().name()) + ? activeProjects.stream() + .flatMap(project -> project.memberships().stream()) + .filter(membership -> membership.isCurrent() + && accounts.isEligibleIntern(membership.internUserId())) + .map(membership -> membership.internUserId()) + .distinct() + .count() + : 0L; + return new ProjectDashboardSummary(activeProjects.size(), distinctActiveMembers); + } + + private ProjectEntity visibleProject(long actorUserId, long projectId) { + var actor = activeActor(actorUserId); + var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new); + var visible = "ADMIN".equals(actor.role().name()) + || ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId) + || ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId)); + if (!visible) { + throw new ProjectAccessDeniedException(); + } + return project; + } + + private List visibleProjects(AccountIdentity actor, long actorUserId) { + return switch (actor.role().name()) { + case "ADMIN" -> projects.findAllByOrderByUpdatedAtDescIdDesc(); + case "MENTOR" -> projects.findByMentorUserIdOrderByUpdatedAtDescIdDesc(actorUserId); + case "INTERN" -> projects.findVisibleToIntern(actorUserId); + default -> throw new ProjectAccessDeniedException(); + }; + } + + private AccountIdentity activeActor(long actorUserId) { + try { + var actor = accounts.requireIdentityById(actorUserId); + if (!"ACTIVE".equals(actor.status().name())) { + throw new ProjectAccessDeniedException(); + } + return actor; + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + private String displayName(long userId) { + try { + return accounts.requireIdentityById(userId).displayName(); + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + private boolean isEligibleIntern(long userId) { + return accounts.isEligibleIntern(userId); + } + + private static ProjectSummary summary(ProjectEntity project) { + return new ProjectSummary( + project.id(), + project.name(), + project.status().name(), + project.startDate(), + project.endDate()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java new file mode 100644 index 0000000..31c994f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -0,0 +1,82 @@ +package com.lab.labtimesheet.feature.project.service; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.repository.ProjectRepository; +import java.time.Clock; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectService { + + private final ProjectRepository projects; + private final AccountService accounts; + private final Clock clock; + + public ProjectService( + ProjectRepository projects, + AccountService accounts, + Clock clock) { + this.projects = projects; + this.accounts = accounts; + this.clock = clock; + } + + @Transactional + public long create(long actorUserId, ProjectCreateCommand command) { + requireActiveMentor(actorUserId); + var project = ProjectEntity.plan( + actorUserId, + command.name(), + command.description(), + command.startDate(), + command.endDate(), + eligibleIntern(command.initialLeaderUserId()), + clock.instant()); + return projects.saveAndFlush(project).id(); + } + + @Transactional + public void addMember(long actorUserId, long projectId, long internUserId) { + var project = lockedProject(projectId); + project.authorizeOwner(actorUserId); + project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant()); + projects.flush(); + } + + @Transactional + public void changeLeader(long actorUserId, long projectId, long internUserId) { + var project = lockedProject(projectId); + project.authorizeOwner(actorUserId); + var change = project.prepareLeaderChange(actorUserId, eligibleIntern(internUserId), clock.instant()); + + // PostgreSQL rejects overlapping terms immediately. Flush the old term's + // end before inserting its replacement; the transaction remains atomic. + projects.flush(); + project.completeLeaderChange(actorUserId, change); + projects.flush(); + } + + private ProjectEntity lockedProject(long projectId) { + return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); + } + + private ProjectInternEligibility eligibleIntern(long userId) { + return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId)); + } + + private void requireActiveMentor(long userId) { + try { + var identity = accounts.requireIdentityById(userId); + if (!"MENTOR".equals(identity.role().name()) || !"ACTIVE".equals(identity.status().name())) { + throw new ProjectAccessDeniedException(); + } + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java deleted file mode 100644 index 47777c2..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public record EligibleIntern(long userId, boolean accountActive, boolean internshipActive) { - - public EligibleIntern { - if (userId <= 0) { - throw new IllegalArgumentException("Intern user ID must be positive"); - } - } - - public boolean isEligible() { - return accountActive && internshipActive; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java deleted file mode 100644 index b91850a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; - -public final class LeadershipTerm { - - private final ProjectMembership membership; - private final Instant startedAt; - private final long appointedByMentorUserId; - private Instant endedAt; - private Long endedByMentorUserId; - - LeadershipTerm(ProjectMembership membership, Instant startedAt, long appointedByMentorUserId) { - this.membership = membership; - this.startedAt = startedAt; - this.appointedByMentorUserId = appointedByMentorUserId; - } - - public long internUserId() { - return membership.internUserId(); - } - - public Instant startedAt() { - return startedAt; - } - - public long appointedByMentorUserId() { - return appointedByMentorUserId; - } - - public Instant endedAt() { - return endedAt; - } - - public Long endedByMentorUserId() { - return endedByMentorUserId; - } - - public boolean isCurrent() { - return endedAt == null; - } - - void end(Instant at, long mentorUserId) { - if (!isCurrent() || !at.isAfter(startedAt)) { - throw new ProjectRuleViolation("Leadership term end must follow its start"); - } - endedAt = at; - endedByMentorUserId = mentorUserId; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java deleted file mode 100644 index e81fa40..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public final class Project { - - private final long mentorUserId; - private final String name; - private final String description; - private final LocalDate startDate; - private final LocalDate endDate; - private final List memberships = new ArrayList<>(); - private final List leadershipTerms = new ArrayList<>(); - private ProjectStatus status = ProjectStatus.PLANNED; - private Instant activatedAt; - - private Project( - long mentorUserId, - String name, - String description, - LocalDate startDate, - LocalDate endDate) { - this.mentorUserId = mentorUserId; - this.name = name; - this.description = description; - this.startDate = startDate; - this.endDate = endDate; - } - - public static Project plan( - long mentorUserId, - String name, - String description, - LocalDate startDate, - LocalDate endDate, - EligibleIntern initialLeader, - Instant at) { - if (mentorUserId <= 0) { - throw new IllegalArgumentException("Mentor user ID must be positive"); - } - var normalizedName = requireText(name, "Project name is required"); - Objects.requireNonNull(startDate, "startDate"); - Objects.requireNonNull(endDate, "endDate"); - Objects.requireNonNull(at, "at"); - if (endDate.isBefore(startDate)) { - throw new ProjectRuleViolation("Project end date must not precede its start date"); - } - requireEligible(initialLeader); - - var project = new Project( - mentorUserId, - normalizedName, - normalizeOptionalText(description), - startDate, - endDate); - var membership = project.addEligibleMember(initialLeader, mentorUserId, at); - project.leadershipTerms.add(new LeadershipTerm(membership, at, mentorUserId)); - return project; - } - - public ProjectMembership addMember(long actorMentorUserId, EligibleIntern intern, Instant at) { - requireOwner(actorMentorUserId); - requireMutable(); - requireEligible(intern); - Objects.requireNonNull(at, "at"); - if (hasCurrentMember(intern.userId())) { - throw new ProjectRuleViolation("Intern is already a current Project member"); - } - return addEligibleMember(intern, actorMentorUserId, at); - } - - public void changeLeader(long actorMentorUserId, EligibleIntern intern, Instant at) { - requireOwner(actorMentorUserId); - requireMutable(); - requireEligible(intern); - Objects.requireNonNull(at, "at"); - var replacement = currentMembership(intern.userId()); - var current = currentLeadershipTerm(); - if (current.internUserId() == intern.userId()) { - throw new ProjectRuleViolation("Selected Intern is already the current Leader"); - } - - current.end(at, actorMentorUserId); - leadershipTerms.add(new LeadershipTerm(replacement, at, actorMentorUserId)); - } - - public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { - requireOwner(actorMentorUserId); - Objects.requireNonNull(at, "at"); - if (status != ProjectStatus.PLANNED) { - throw new ProjectRuleViolation("Only a planned Project can be activated"); - } - if (memberships.stream().noneMatch(ProjectMembership::isCurrent) - || leadershipTerms.stream().noneMatch(LeadershipTerm::isCurrent)) { - throw new ProjectRuleViolation("Project requires a current member and Leader"); - } - if (!allTaskAssigneesAreCurrent) { - throw new ProjectRuleViolation("Every current Task assignee must be an active Project member"); - } - status = ProjectStatus.ACTIVE; - activatedAt = at; - } - - public long mentorUserId() { - return mentorUserId; - } - - public String name() { - return name; - } - - public String description() { - return description; - } - - public LocalDate startDate() { - return startDate; - } - - public LocalDate endDate() { - return endDate; - } - - public ProjectStatus status() { - return status; - } - - public Instant activatedAt() { - return activatedAt; - } - - public List memberships() { - return List.copyOf(memberships); - } - - public List leadershipTerms() { - return List.copyOf(leadershipTerms); - } - - public boolean hasCurrentMember(long internUserId) { - return memberships.stream() - .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); - } - - public ProjectMembership currentLeader() { - return currentMembership(currentLeadershipTerm().internUserId()); - } - - private ProjectMembership addEligibleMember(EligibleIntern intern, long addedByUserId, Instant at) { - var membership = new ProjectMembership(intern.userId(), at, addedByUserId); - memberships.add(membership); - return membership; - } - - private ProjectMembership currentMembership(long internUserId) { - return memberships.stream() - .filter(membership -> membership.internUserId() == internUserId && membership.isCurrent()) - .findFirst() - .orElseThrow(() -> new ProjectRuleViolation("Leader must be a current same-Project member")); - } - - private LeadershipTerm currentLeadershipTerm() { - return leadershipTerms.stream() - .filter(LeadershipTerm::isCurrent) - .findFirst() - .orElseThrow(() -> new ProjectRuleViolation("Project has no current Leader")); - } - - private void requireOwner(long actorMentorUserId) { - if (mentorUserId != actorMentorUserId) { - throw new ProjectAccessDenied(); - } - } - - private void requireMutable() { - if (status == ProjectStatus.COMPLETED) { - throw new ProjectRuleViolation("Completed Projects are read-only"); - } - } - - private static void requireEligible(EligibleIntern intern) { - Objects.requireNonNull(intern, "intern"); - if (!intern.isEligible()) { - throw new ProjectRuleViolation("Intern must have an active account and internship"); - } - } - - private static String requireText(String value, String message) { - if (value == null || value.trim().isEmpty()) { - throw new ProjectRuleViolation(message); - } - return value.trim(); - } - - private static String normalizeOptionalText(String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - return value.trim(); - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java deleted file mode 100644 index c60789a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public final class ProjectAccessDenied extends RuntimeException { - - public ProjectAccessDenied() { - super("Project access denied"); - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java deleted file mode 100644 index a48b86a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; - -public final class ProjectMembership { - - private final long internUserId; - private final Instant joinedAt; - private final long addedByUserId; - private Instant leftAt; - - ProjectMembership(long internUserId, Instant joinedAt, long addedByUserId) { - this.internUserId = internUserId; - this.joinedAt = joinedAt; - this.addedByUserId = addedByUserId; - } - - public long internUserId() { - return internUserId; - } - - public Instant joinedAt() { - return joinedAt; - } - - public long addedByUserId() { - return addedByUserId; - } - - public Instant leftAt() { - return leftAt; - } - - public boolean isCurrent() { - return leftAt == null; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java deleted file mode 100644 index 4357c9c..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public final class ProjectRuleViolation extends RuntimeException { - - public ProjectRuleViolation(String message) { - super(message); - } -} diff --git a/src/main/resources/templates/projects/detail.html b/src/main/resources/templates/projects/detail.html new file mode 100644 index 0000000..5dd745e --- /dev/null +++ b/src/main/resources/templates/projects/detail.html @@ -0,0 +1,12 @@ + + +Project + +
+

Project

+

+
Status
Mentor
Leader
+ +
+ + diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html new file mode 100644 index 0000000..2834cfa --- /dev/null +++ b/src/main/resources/templates/projects/form.html @@ -0,0 +1,19 @@ + + +Create Project + +
+

Create Project

+
+ +

+ + + + +

+ +
+
+ + diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html new file mode 100644 index 0000000..378a69c --- /dev/null +++ b/src/main/resources/templates/projects/leadership.html @@ -0,0 +1,13 @@ + + +Project leadership + +
+

Project leadership

+ + +
Leadership history
LeaderStartedEnded
+
+
+ + diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html new file mode 100644 index 0000000..9379ac8 --- /dev/null +++ b/src/main/resources/templates/projects/list.html @@ -0,0 +1,20 @@ + + +Projects + +
+

Projects

+ Create Project +

No authorized Projects.

+ + + + + + + + +
Authorized Projects
NameStatusDates
ProjectPLANNEDStartEnd
+
+ + diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html new file mode 100644 index 0000000..e0238eb --- /dev/null +++ b/src/main/resources/templates/projects/members.html @@ -0,0 +1,13 @@ + + +Project members + +
+

Project members

+ + +
Membership history
InternJoinedLeftRole
+
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java new file mode 100644 index 0000000..ed3ef9f --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -0,0 +1,142 @@ +package com.lab.labtimesheet.feature.project.controller; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ProjectController.class) +class ProjectControllerTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean + private ProjectQueryService pages; + + @MockitoBean + private ProjectService projects; + + @Test + @WithMockUser(username = "mentor@example.test") + void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary( + 30L, + "Intern Portal Refresh", + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30)))); + + mvc.perform(get("/projects")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/list")) + .andExpect(model().attributeExists("projects")); + + verify(pages).listVisible(10L); + } + + @Test + @WithMockUser(username = "member@example.test") + void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception { + when(pages.authenticatedUserId("member@example.test")).thenReturn(20L); + when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException()); + + mvc.perform(get("/projects/999")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = "member@example.test") + void memberAndLeadershipPagesUseTheSameProjectScopedAuthorization() throws Exception { + when(pages.authenticatedUserId("member@example.test")).thenReturn(20L); + when(pages.detail(20L, 30L)).thenReturn(new ProjectDetail( + 30L, + "Intern Portal Refresh", + null, + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + "Leader")); + when(pages.members(20L, 30L)).thenReturn(List.of()); + when(pages.leadership(20L, 30L)).thenReturn(List.of()); + + mvc.perform(get("/projects/30/members")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/members")); + mvc.perform(get("/projects/30/leadership")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/leadership")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(projects.create( + 10L, + new ProjectCreateCommand( + "Intern Portal Refresh", + "Refresh portal", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + 20L))) + .thenReturn(30L); + + mvc.perform(post("/projects") + .with(csrf()) + .param("name", "Intern Portal Refresh") + .param("description", "Refresh portal") + .param("startDate", "2026-08-15") + .param("endDate", "2026-09-30") + .param("initialLeaderUserId", "20")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/30")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception { + mvc.perform(post("/projects") + .with(csrf()) + .param("name", " ") + .param("startDate", "2026-09-30") + .param("endDate", "2026-08-15") + .param("initialLeaderUserId", "0")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/form")) + .andExpect(model().attributeHasFieldErrors( + "projectForm", "name", "initialLeaderUserId")); + + verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void stateChangingRoutesRequireCsrf() throws Exception { + mvc.perform(post("/projects")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java similarity index 70% rename from src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java rename to src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java index 8583541..16f1a2d 100644 --- a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java @@ -1,21 +1,25 @@ -package com.lab.labtimesheet.projects.domain; +package com.lab.labtimesheet.feature.project.model.entity; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; import java.time.Instant; import java.time.LocalDate; import org.junit.jupiter.api.Test; -class ProjectTest { +class ProjectEntityTest { private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z"); @Test void planningCreatesTheInitialLeaderMembershipAndTermTogether() { - var project = Project.plan( + var project = ProjectEntity.plan( 10L, " Intern Portal Refresh ", " Refresh the portal ", @@ -36,7 +40,7 @@ class ProjectTest { @Test void planningRejectsAnIneligibleInitialLeaderAndInvalidDates() { - assertThrows(ProjectRuleViolation.class, () -> Project.plan( + assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan( 10L, "Project", null, @@ -44,13 +48,13 @@ class ProjectTest { LocalDate.of(2026, 8, 31), activeIntern(20L), CREATED_AT)); - assertThrows(ProjectRuleViolation.class, () -> Project.plan( + assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan( 10L, "Project", null, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), - new EligibleIntern(20L, false, true), + new ProjectInternEligibility(20L, false), CREATED_AT)); } @@ -62,16 +66,16 @@ class ProjectTest { assertEquals(2, project.memberships().size()); assertTrue(project.hasCurrentMember(21L)); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120))); - assertThrows(ProjectAccessDenied.class, + assertThrows(ProjectAccessDeniedException.class, () -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120))); } @Test void theSameInternCanBelongToSeparateProjects() { var first = plannedProject(); - var second = Project.plan( + var second = ProjectEntity.plan( 11L, "Second", null, @@ -91,38 +95,39 @@ class ProjectTest { var project = plannedProject(); project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60)); - project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)); + var change = project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)); + project.completeLeaderChange(10L, change); assertEquals(2, project.memberships().size()); assertEquals(2, project.leadershipTerms().size()); - assertEquals(1, project.leadershipTerms().stream().filter(LeadershipTerm::isCurrent).count()); + assertEquals(1, project.leadershipTerms().stream().filter(ProjectLeadershipTermEntity::isCurrent).count()); assertEquals(21L, project.currentLeader().internUserId()); assertFalse(project.leadershipTerms().getFirst().isCurrent()); - assertThrows(ProjectRuleViolation.class, - () -> project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(180))); - assertThrows(ProjectRuleViolation.class, - () -> project.changeLeader(10L, activeIntern(22L), CREATED_AT.plusSeconds(180))); + assertThrows(ProjectRuleViolationException.class, + () -> project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(180))); + assertThrows(ProjectRuleViolationException.class, + () -> project.prepareLeaderChange(10L, activeIntern(22L), CREATED_AT.plusSeconds(180))); } @Test void activationRequiresOwnerAndValidCurrentTaskAssignees() { var project = plannedProject(); - assertThrows(ProjectAccessDenied.class, + assertThrows(ProjectAccessDeniedException.class, () -> project.activate(11L, true, CREATED_AT.plusSeconds(60))); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.activate(10L, false, CREATED_AT.plusSeconds(60))); project.activate(10L, true, CREATED_AT.plusSeconds(60)); assertEquals(ProjectStatus.ACTIVE, project.status()); assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt()); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.activate(10L, true, CREATED_AT.plusSeconds(120))); } - private static Project plannedProject() { - return Project.plan( + private static ProjectEntity plannedProject() { + return ProjectEntity.plan( 10L, "Project", null, @@ -132,7 +137,7 @@ class ProjectTest { CREATED_AT); } - private static EligibleIntern activeIntern(long userId) { - return new EligibleIntern(userId, true, true); + private static ProjectInternEligibility activeIntern(long userId) { + return new ProjectInternEligibility(userId, true); } } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java new file mode 100644 index 0000000..a3bab65 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java @@ -0,0 +1,31 @@ +package com.lab.labtimesheet.feature.project.repository; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.jdbc.core.JdbcOperations; + +class ProjectPersistenceStructureTest { + + @Test + void projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa() { + assertTrue(ProjectEntity.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.model.entity")); + assertTrue(ProjectService.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.service")); + assertTrue(JpaRepository.class.isAssignableFrom(ProjectRepository.class)); + assertTrue(ProjectRepository.class.getInterfaces().length > 0); + assertTrue(ProjectEntity.class.isAnnotationPresent(jakarta.persistence.Entity.class)); + assertFalse(Arrays.stream(ProjectService.class.getDeclaredFields()) + .map(field -> field.getType()) + .anyMatch(JdbcOperations.class::isAssignableFrom)); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectUserEntity")); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectTaskEntity")); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java new file mode 100644 index 0000000..8db027a --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -0,0 +1,231 @@ +package com.lab.labtimesheet.feature.project.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ProjectServiceIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z"); + + @Autowired + private ProjectService projectService; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ProjectQueryService projectPages; + + @PersistenceContext + private EntityManager entityManager; + + @Test + void createsProjectMembershipAndLeadershipInOneTransaction() { + long mentorId = user("mentor-create@example.test", "MENTOR"); + long leaderId = intern("leader-create@example.test", "I001"); + + long projectId = projectService.create( + mentorId, + new ProjectCreateCommand( + "Intern Portal Refresh", + "Refresh the portal", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + leaderId)); + + assertEquals("PLANNED", text("select status from projects where id = ?", projectId)); + assertEquals(1, count("select count(*) from project_memberships where project_id = ? and left_at is null", projectId)); + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId)); + assertEquals(leaderId, number(""" + select membership.intern_user_id + from project_leadership_terms leadership + join project_memberships membership on membership.id = leadership.membership_id + where leadership.project_id = ? and leadership.ended_at is null + """, projectId)); + + long nonMentorId = intern("not-mentor@example.test", "I002"); + assertThrows(ProjectAccessDeniedException.class, () -> projectService.create( + nonMentorId, + new ProjectCreateCommand( + "Denied", + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 8, 31), + leaderId))); + assertEquals(0, count("select count(*) from projects where name = 'Denied'")); + } + + @Test + void ownerAddsEligibleMemberAndDuplicateCurrentMembershipIsRejected() { + long mentorId = user("mentor-add@example.test", "MENTOR"); + long leaderId = intern("leader-add@example.test", "I003"); + long memberId = intern("member-add@example.test", "I004"); + long projectId = createProject(mentorId, leaderId, "Membership"); + long otherProjectId = createProject(mentorId, memberId, "Concurrent membership"); + + projectService.addMember(mentorId, projectId, memberId); + + assertEquals(1, count(""" + select count(*) from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, projectId, memberId)); + assertEquals(1, count(""" + select count(*) from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, otherProjectId, memberId)); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMember(mentorId, projectId, memberId)); + assertThrows(ProjectAccessDeniedException.class, + () -> projectService.addMember( + user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE)); + } + + @Test + void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() { + long mentorId = user("mentor-leader@example.test", "MENTOR"); + long firstLeaderId = intern("leader-one@example.test", "I005"); + long nextLeaderId = intern("leader-two@example.test", "I006"); + long projectId = createProject(mentorId, firstLeaderId, "Leadership"); + projectService.addMember(mentorId, projectId, nextLeaderId); + long firstMembershipId = membershipId(projectId, firstLeaderId); + jdbc.update(""" + insert into tasks ( + project_id, assignee_membership_id, title, + created_by_membership_id, assigned_by_membership_id) + values (?, ?, 'Keep assignee', ?, ?) + """, projectId, firstMembershipId, firstMembershipId, firstMembershipId); + + projectService.changeLeader(mentorId, projectId, nextLeaderId); + + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId)); + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is not null", projectId)); + assertEquals(firstMembershipId, number("select assignee_membership_id from tasks where project_id = ?", projectId)); + } + + @Test + void listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure() { + long adminId = user("admin-view@example.test", "ADMIN"); + long mentorId = user("mentor-view@example.test", "MENTOR"); + long otherMentorId = user("other-mentor-view@example.test", "MENTOR"); + long leaderId = intern("leader-view@example.test", "I009"); + long memberId = intern("member-view@example.test", "I010"); + long unrelatedId = intern("unrelated-view@example.test", "I011"); + long projectId = createProject(mentorId, leaderId, "Visible project"); + projectService.addMember(mentorId, projectId, memberId); + + assertEquals(List.of(projectId), projectPages.listVisible(adminId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(projectId), projectPages.listVisible(mentorId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(), projectPages.listVisible(otherMentorId)); + assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(), projectPages.listVisible(unrelatedId)); + assertEquals(projectId, projectPages.detail(memberId, projectId).id()); + assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role()); + var taskContext = projectPages.taskContext(memberId, projectId); + assertEquals(mentorId, taskContext.mentorUserId()); + assertEquals("PLANNED", taskContext.status()); + assertEquals(2, taskContext.activeMembers().size()); + assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId()); + jdbc.update(""" + update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ? + """, dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId); + entityManager.clear(); + assertEquals(1, projectPages.dashboardSummary(adminId).activeProjectCount()); + assertEquals(1, projectPages.dashboardSummary(mentorId).activeProjectCount()); + assertEquals(2, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); + assertEquals(1, projectPages.dashboardSummary(memberId).activeProjectCount()); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(otherMentorId, projectId)); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, projectId)); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, Long.MAX_VALUE)); + + jdbc.update(""" + update project_memberships + set left_at = ?, removed_by_mentor_user_id = ? + where project_id = ? and intern_user_id = ? + """, dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId); + entityManager.clear(); + + assertEquals(projectId, projectPages.detail(memberId, projectId).id()); + assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream() + .noneMatch(member -> member.userId() == memberId)); + assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount()); + assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); + } + + private long createProject(long mentorId, long leaderId, String name) { + return projectService.create( + mentorId, + new ProjectCreateCommand( + name, + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + leaderId)); + } + + private long user(String email, String role) { + return jdbc.queryForObject(""" + insert into app_users ( + email, display_name, password_hash, global_role, account_status, activated_at) + values (?, ?, '{noop}password-password', ?, 'ACTIVE', ?) + returning id + """, Long.class, email, email, role, dbTime(NOW)); + } + + private long intern(String email, String studentCode) { + long userId = user(email, "INTERN"); + jdbc.update(""" + insert into intern_profiles ( + user_id, student_code, internship_start_date, internship_end_date, + internship_status, activated_at) + values (?, ?, date '2026-08-01', date '2026-12-31', 'ACTIVE', ?) + """, userId, studentCode, dbTime(NOW)); + return userId; + } + + private long membershipId(long projectId, long internUserId) { + return number(""" + select id from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, projectId, internUserId); + } + + private int count(String sql, Object... arguments) { + return jdbc.queryForObject(sql, Integer.class, arguments); + } + + private long number(String sql, Object... arguments) { + return jdbc.queryForObject(sql, Long.class, arguments); + } + + private String text(String sql, Object... arguments) { + return jdbc.queryForObject(sql, String.class, arguments); + } + + private OffsetDateTime dbTime(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } +}