Merge commit '25a855e' into work/tasks

This commit is contained in:
sechmachine
2026-08-15 00:26:19 +07:00
34 changed files with 1743 additions and 0 deletions
@@ -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());
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.project.exception;
public final class ProjectAccessDeniedException extends RuntimeException {
public ProjectAccessDeniedException() {
super("Project access denied");
}
}
@@ -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() {
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.project.exception;
public final class ProjectRuleViolationException extends RuntimeException {
public ProjectRuleViolationException(String message) {
super(message);
}
}
@@ -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;
}
}
@@ -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) {
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.project.model;
public enum ProjectStatus {
PLANNED,
ACTIVE,
COMPLETED
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectActorView(long userId, String role) {
}
@@ -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) {
}
@@ -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);
}
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) {
}
@@ -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) {
}
@@ -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) {
}
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.Positive;
public record ProjectMemberForm(@Positive long internUserId) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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<ProjectTaskMemberView> activeMembers) {
public ProjectTaskContext {
activeMembers = List.copyOf(activeMembers);
}
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectTaskMemberView(long membershipId, long userId, String displayName) {
}
@@ -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<ProjectMembershipEntity> memberships = new ArrayList<>();
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL)
private List<ProjectLeadershipTermEntity> 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<ProjectMembershipEntity> memberships() {
return List.copyOf(memberships);
}
public List<ProjectLeadershipTermEntity> 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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<ProjectEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select project from ProjectEntity project where project.id = :id")
Optional<ProjectEntity> findLockedById(@Param("id") long id);
List<ProjectEntity> findAllByOrderByUpdatedAtDescIdDesc();
List<ProjectEntity> 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<ProjectEntity> findVisibleToIntern(@Param("internUserId") long internUserId);
}
@@ -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<ProjectSummary> 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<ProjectMemberView> 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<ProjectLeadershipTermView> 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<ProjectEntity> 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());
}
}
@@ -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();
}
}
}
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title th:text="${project.name}">Project</title></head>
<body>
<main>
<h1 th:text="${project.name}">Project</h1>
<p th:text="${project.description}"></p>
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl>
<nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav>
</main>
</body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Create Project</title></head>
<body>
<main>
<h1>Create Project</h1>
<form method="post" th:action="@{/projects}" th:object="${projectForm}">
<label for="name">Name</label><input id="name" th:field="*{name}" required maxlength="160">
<p th:errors="*{name}"></p>
<label for="description">Description</label><textarea id="description" th:field="*{description}"></textarea>
<label for="startDate">Start date</label><input id="startDate" type="date" th:field="*{startDate}" required>
<label for="endDate">End date</label><input id="endDate" type="date" th:field="*{endDate}" required>
<label for="leader">Initial Leader user ID</label><input id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required>
<p th:errors="*{initialLeaderUserId}"></p>
<button type="submit">Create Project</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Project leadership</title></head>
<body>
<main>
<h1><span th:text="${project.name}">Project</span> leadership</h1>
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
</table>
<form method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
</main>
</body>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Projects</title></head>
<body>
<main>
<h1>Projects</h1>
<a href="/projects/new">Create Project</a>
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
<table th:unless="${#lists.isEmpty(projects)}">
<caption>Authorized Projects</caption>
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Dates</th></tr></thead>
<tbody><tr th:each="project : ${projects}">
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
<td th:text="${project.status}">PLANNED</td>
<td><span th:text="${project.startDate}">Start</span> <span th:text="${project.endDate}">End</span></td>
</tr></tbody>
</table>
</main>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Project members</title></head>
<body>
<main>
<h1><span th:text="${project.name}">Project</span> members</h1>
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
</table>
<form method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
</main>
</body>
</html>