Merge commit 'c3f92d4e310d625d95d374e974c478444331f6d7' into work/tasks

This commit is contained in:
sechmachine
2026-08-15 01:06:20 +07:00
15 changed files with 454 additions and 22 deletions
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
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;
@@ -29,12 +30,17 @@ public class ProjectController {
@GetMapping
public String list(Principal principal, Model model) {
model.addAttribute("projects", pages.listVisible(actorId(principal)));
var actor = pages.authenticatedActor(principal.getName());
model.addAttribute("projects", pages.listVisible(actor.userId()));
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
return "projects/list";
}
@GetMapping("/new")
public String createForm(Model model) {
public String createForm(Principal principal, Model model) {
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
throw new ProjectAccessDeniedException();
}
model.addAttribute("projectForm", new ProjectCreateForm());
return "projects/form";
}
@@ -10,5 +10,6 @@ public record ProjectDetail(
LocalDate startDate,
LocalDate endDate,
String mentorName,
String leaderName) {
String leaderName,
boolean canManage) {
}
@@ -64,7 +64,8 @@ public class ProjectQueryService {
project.startDate(),
project.endDate(),
displayName(project.mentorUserId()),
displayName(project.currentLeader().internUserId()));
displayName(project.currentLeader().internUserId()),
project.mentorUserId() == actorUserId);
}
@Transactional(readOnly = true)
@@ -96,7 +97,12 @@ public class ProjectQueryService {
@Transactional(readOnly = true)
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
return taskContext(actorUserId, project);
}
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
requireVisibleProject(actorUserId, project);
var activeMembers = project.memberships().stream()
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
.map(membership -> new ProjectTaskMemberView(
@@ -141,15 +147,19 @@ public class ProjectQueryService {
}
private ProjectEntity visibleProject(long actorUserId, long projectId) {
var actor = activeActor(actorUserId);
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
requireVisibleProject(actorUserId, project);
return project;
}
private void requireVisibleProject(long actorUserId, ProjectEntity project) {
var actor = activeActor(actorUserId);
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) {
@@ -4,6 +4,7 @@ import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedExcepti
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.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.time.Clock;
@@ -15,14 +16,17 @@ public class ProjectService {
private final ProjectRepository projects;
private final AccountService accounts;
private final ProjectQueryService queries;
private final Clock clock;
public ProjectService(
ProjectRepository projects,
AccountService accounts,
ProjectQueryService queries,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.queries = queries;
this.clock = clock;
}
@@ -61,6 +65,11 @@ public class ProjectService {
projects.flush();
}
@Transactional
public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) {
return queries.taskContext(actorUserId, lockedProject(projectId));
}
private ProjectEntity lockedProject(long projectId) {
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
}
@@ -7,7 +7,7 @@
<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>
<form th:if="${project.canManage}" 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>
@@ -4,7 +4,7 @@
<body>
<main>
<h1>Projects</h1>
<a href="/projects/new">Create Project</a>
<a th:if="${canCreateProject}" 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>
@@ -7,7 +7,7 @@
<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>
<form th:if="${project.canManage}" 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>