feat(tasks): persist iteration 1 task workflow

This commit is contained in:
sechmachine
2026-08-14 23:41:42 +07:00
parent d2d3cc57c3
commit 597ebf1b49
11 changed files with 937 additions and 1 deletions
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.tasks;
import java.time.LocalDate;
public record CreateTaskCommand(
long projectId,
long assigneeMembershipId,
String title,
String description,
LocalDate dueDate) {}
@@ -0,0 +1,5 @@
package com.lab.labtimesheet.tasks;
import java.time.Instant;
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.tasks;
import java.util.List;
public record TaskDetails(TaskView task, List<TaskCommentView> comments) {
public TaskDetails {
comments = List.copyOf(comments);
}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.tasks;
import java.util.List;
public record TaskListView(List<TaskView> tasks, TaskProgress progress) {
public TaskListView {
tasks = List.copyOf(tasks);
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.tasks;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public final class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException() {
super("Task or Project was not found");
}
}
@@ -0,0 +1,412 @@
package com.lab.labtimesheet.tasks;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskService {
private final JdbcClient jdbc;
public TaskService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Transactional
public TaskView create(String actorEmail, CreateTaskCommand command) {
String title = requireTitle(command.title());
Actor actor = requireActiveActor(actorEmail);
Project project = requireOpenProject(command.projectId());
long actorMembershipId = requireActorMembership(project.id(), actor.id());
requireAssigneeMembership(project.id(), command.assigneeMembershipId());
if (actorMembershipId != command.assigneeMembershipId()
&& !isCurrentLeader(project.id(), actorMembershipId)) {
throw new TaskNotFoundException();
}
validateDueDate(project, command.dueDate());
long taskId = jdbc.sql("""
insert into tasks
(project_id, assignee_membership_id, title, description, due_date,
created_by_membership_id, assigned_by_membership_id)
values (:projectId, :assigneeId, :title, :description, :dueDate,
:actorMembershipId, :actorMembershipId)
returning id
""")
.param("projectId", project.id())
.param("assigneeId", command.assigneeMembershipId())
.param("title", title)
.param("description", trimToNull(command.description()))
.param("dueDate", command.dueDate())
.param("actorMembershipId", actorMembershipId)
.query(Long.class)
.single();
return task(taskId);
}
@Transactional
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
Actor actor = requireActiveActor(actorEmail);
TaskView task = jdbc.sql("""
select t.id, t.project_id, t.assignee_membership_id, t.title, t.description,
t.status, t.due_date, t.created_by_membership_id,
t.assigned_by_membership_id, t.assigned_at, t.created_at
from tasks t
join projects p on p.id = t.project_id
join project_memberships m on m.id = t.assignee_membership_id
and m.project_id = t.project_id
where t.id = :taskId
and t.project_id = :projectId
and t.deleted_at is null
and p.status = 'ACTIVE'
and m.intern_user_id = :actorId
and m.left_at is null
""")
.param("taskId", taskId)
.param("projectId", projectId)
.param("actorId", actor.id())
.query(TaskService::mapTask)
.optional()
.orElseThrow(TaskNotFoundException::new);
if (!task.status().canTransitionTo(target)) {
throw new TaskValidationException("Task status transition is not allowed");
}
jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId")
.param("status", target.name())
.param("taskId", taskId)
.update();
return task(taskId);
}
@Transactional
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
String normalizedBody = requireCommentBody(body);
Actor actor = requireActiveActor(actorEmail);
ProjectAccess project = requireProjectAccess(projectId);
if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) {
throw new TaskNotFoundException();
}
if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) {
throw new TaskNotFoundException();
}
long commentId = jdbc.sql("""
insert into task_comments (task_id, author_user_id, body)
values (:taskId, :actorId, :body)
returning id
""")
.param("taskId", taskId)
.param("actorId", actor.id())
.param("body", normalizedBody)
.query(Long.class)
.single();
return comment(commentId);
}
@Transactional(readOnly = true)
public TaskListView list(String actorEmail, long projectId) {
requireViewAccess(actorEmail, projectId);
List<TaskView> tasks = jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where project_id = :projectId and deleted_at is null
order by id
""")
.param("projectId", projectId)
.query(TaskService::mapTask)
.list();
return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList()));
}
@Transactional(readOnly = true)
public TaskDetails details(String actorEmail, long projectId, long taskId) {
requireViewAccess(actorEmail, projectId);
TaskView task = jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where id = :taskId and project_id = :projectId and deleted_at is null
""")
.param("taskId", taskId)
.param("projectId", projectId)
.query(TaskService::mapTask)
.optional()
.orElseThrow(TaskNotFoundException::new);
List<TaskCommentView> comments = jdbc.sql("""
select id, task_id, author_user_id, body, created_at
from task_comments
where task_id = :taskId
order by created_at, id
""")
.param("taskId", taskId)
.query(TaskService::mapComment)
.list();
return new TaskDetails(task, comments);
}
private Actor requireActiveActor(String email) {
Actor actor = requireReadableActor(email);
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
throw new TaskNotFoundException();
}
return actor;
}
private Actor requireReadableActor(String email) {
return jdbc.sql("""
select u.id, u.global_role, i.internship_status
from app_users u
left join intern_profiles i on i.user_id = u.id
where lower(btrim(u.email)) = lower(btrim(:email))
and u.account_status = 'ACTIVE'
""")
.param("email", email)
.query((rs, rowNum) -> new Actor(
rs.getLong("id"),
rs.getString("global_role"),
rs.getString("internship_status")))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private Project requireOpenProject(long projectId) {
return jdbc.sql("""
select id, status, start_date, end_date
from projects
where id = :projectId and status in ('PLANNED', 'ACTIVE')
""")
.param("projectId", projectId)
.query((rs, rowNum) -> new Project(
rs.getLong("id"),
rs.getString("status"),
rs.getObject("start_date", LocalDate.class),
rs.getObject("end_date", LocalDate.class)))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private long requireActorMembership(long projectId, long userId) {
return jdbc.sql("""
select m.id
from project_memberships m
join app_users u on u.id = m.intern_user_id
join intern_profiles i on i.user_id = m.intern_user_id
where m.project_id = :projectId
and m.intern_user_id = :userId
and m.left_at is null
and u.account_status = 'ACTIVE'
and i.internship_status = 'ACTIVE'
""")
.param("projectId", projectId)
.param("userId", userId)
.query(Long.class)
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private void requireAssigneeMembership(long projectId, long membershipId) {
boolean exists = jdbc.sql("""
select exists (
select 1
from project_memberships m
join app_users u on u.id = m.intern_user_id
join intern_profiles i on i.user_id = m.intern_user_id
where m.id = :membershipId
and m.project_id = :projectId
and m.left_at is null
and u.account_status = 'ACTIVE'
and i.internship_status = 'ACTIVE'
)
""")
.param("membershipId", membershipId)
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (!exists) {
throw new TaskNotFoundException();
}
}
private boolean isCurrentLeader(long projectId, long membershipId) {
return jdbc.sql("""
select exists (
select 1 from project_leadership_terms
where project_id = :projectId
and membership_id = :membershipId
and ended_at is null
)
""")
.param("projectId", projectId)
.param("membershipId", membershipId)
.query(Boolean.class)
.single();
}
private void requireViewAccess(String actorEmail, long projectId) {
Actor actor = requireReadableActor(actorEmail);
ProjectAccess project = requireProjectAccess(projectId);
if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) {
return;
}
boolean member = jdbc.sql("""
select exists (
select 1 from project_memberships
where project_id = :projectId
and intern_user_id = :actorId
and (:completed or left_at is null)
)
""")
.param("projectId", projectId)
.param("actorId", actor.id())
.param("completed", "COMPLETED".equals(project.status()))
.query(Boolean.class)
.single();
if (!member) {
throw new TaskNotFoundException();
}
}
private ProjectAccess requireProjectAccess(long projectId) {
return jdbc.sql("select status, mentor_user_id from projects where id = :projectId")
.param("projectId", projectId)
.query((rs, rowNum) -> new ProjectAccess(
rs.getString("status"), rs.getLong("mentor_user_id")))
.optional()
.orElseThrow(TaskNotFoundException::new);
}
private boolean hasActiveMembership(long projectId, long actorId) {
return jdbc.sql("""
select exists (
select 1 from project_memberships
where project_id = :projectId
and intern_user_id = :actorId
and left_at is null
)
""")
.param("projectId", projectId)
.param("actorId", actorId)
.query(Boolean.class)
.single();
}
private boolean taskExists(long projectId, long taskId) {
return jdbc.sql("""
select exists (
select 1 from tasks
where id = :taskId and project_id = :projectId and deleted_at is null
)
""")
.param("taskId", taskId)
.param("projectId", projectId)
.query(Boolean.class)
.single();
}
private void validateDueDate(Project project, LocalDate dueDate) {
if (dueDate == null) {
return;
}
if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) {
throw new TaskValidationException("Due date must be within Project dates");
}
boolean dayOff = jdbc.sql("""
select exists (
select 1 from global_calendar_events
where calendar_date = :dueDate and is_day_off = true
)
""")
.param("dueDate", dueDate)
.query(Boolean.class)
.single();
if (dayOff) {
throw new TaskValidationException("Due date cannot be a current global day off");
}
}
private TaskView task(long taskId) {
return jdbc.sql("""
select id, project_id, assignee_membership_id, title, description, status,
due_date, created_by_membership_id, assigned_by_membership_id,
assigned_at, created_at
from tasks
where id = :taskId
""")
.param("taskId", taskId)
.query(TaskService::mapTask)
.single();
}
private TaskCommentView comment(long commentId) {
return jdbc.sql("""
select id, task_id, author_user_id, body, created_at
from task_comments
where id = :commentId
""")
.param("commentId", commentId)
.query(TaskService::mapComment)
.single();
}
private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException {
return new TaskView(
rs.getLong("id"),
rs.getLong("project_id"),
rs.getLong("assignee_membership_id"),
rs.getString("title"),
rs.getString("description"),
TaskStatus.valueOf(rs.getString("status")),
rs.getObject("due_date", LocalDate.class),
rs.getLong("created_by_membership_id"),
rs.getLong("assigned_by_membership_id"),
rs.getTimestamp("assigned_at").toInstant(),
rs.getTimestamp("created_at").toInstant());
}
private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException {
return new TaskCommentView(
rs.getLong("id"),
rs.getLong("task_id"),
rs.getLong("author_user_id"),
rs.getString("body"),
rs.getTimestamp("created_at").toInstant());
}
private static String requireTitle(String title) {
String trimmed = trimToNull(title);
if (trimmed == null || trimmed.length() > 200) {
throw new TaskValidationException("Title is required and must not exceed 200 characters");
}
return trimmed;
}
private static String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String requireCommentBody(String body) {
String trimmed = trimToNull(body);
if (trimmed == null) {
throw new TaskValidationException("Comment body is required");
}
return trimmed;
}
private record Actor(long id, String role, String internshipStatus) {}
private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {}
private record ProjectAccess(String status, long mentorUserId) {}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.tasks;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public final class TaskValidationException extends RuntimeException {
public TaskValidationException(String message) {
super(message);
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet.tasks;
import java.time.Instant;
import java.time.LocalDate;
public record TaskView(
long id,
long projectId,
long assigneeMembershipId,
String title,
String description,
TaskStatus status,
LocalDate dueDate,
long creatorMembershipId,
long assignerMembershipId,
Instant assignedAt,
Instant createdAt) {}