From e0d041ef67335c2235f2e759b2224a5fd9dff398 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Wed, 8 Oct 2025 11:07:15 +0200 Subject: [PATCH] Add project and task management with scheduling and notifications Introduced `Project` and `Task` entities, along with supporting services, repositories, and APIs. Added features for project-based and household-level task management, including creation, listing, updates, and validation. Implemented scheduled notifications for tasks due tomorrow. Updated Flyway migrations, configuration files, and tests to support these functionalities. --- .../urmo/hemhub/config/SchedulingConfig.java | 18 +++ .../java/se/urmo/hemhub/domain/Project.java | 30 +++++ src/main/java/se/urmo/hemhub/domain/Task.java | 89 ++++++++++++++ .../se/urmo/hemhub/dto/ProjectTaskDtos.java | 60 ++++++++++ .../hemhub/notify/LogNotificationService.java | 31 +++++ .../hemhub/notify/NotificationService.java | 9 ++ .../urmo/hemhub/repo/ProjectRepository.java | 10 ++ .../se/urmo/hemhub/repo/TaskRepository.java | 36 ++++++ .../hemhub/service/ProjectTaskService.java | 108 +++++++++++++++++ .../urmo/hemhub/service/TaskDueService.java | 38 ++++++ .../hemhub/web/ProjectTaskController.java | 111 ++++++++++++++++++ src/main/resources/application.yml | 6 + .../db/migration/V3__projects_tasks.sql | 30 +++++ .../db/migration/V4__task_household_link.sql | 28 +++++ src/test/http/hemhub-api.http | 67 ++++++++++- .../integration/HouseholdControllerIT.java | 34 ++++-- .../integration/MeControllerBranchesIT.java | 8 +- .../hemhub/integration/MeControllerIT.java | 7 +- .../integration/ProjectTaskControllerIT.java | 70 +++++++++++ .../integration/PublicControllerIT.java | 5 +- .../integration/TaskDueControllerIT.java | 53 +++++++++ 21 files changed, 828 insertions(+), 20 deletions(-) create mode 100644 src/main/java/se/urmo/hemhub/config/SchedulingConfig.java create mode 100644 src/main/java/se/urmo/hemhub/domain/Project.java create mode 100644 src/main/java/se/urmo/hemhub/domain/Task.java create mode 100644 src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java create mode 100644 src/main/java/se/urmo/hemhub/notify/LogNotificationService.java create mode 100644 src/main/java/se/urmo/hemhub/notify/NotificationService.java create mode 100644 src/main/java/se/urmo/hemhub/repo/ProjectRepository.java create mode 100644 src/main/java/se/urmo/hemhub/repo/TaskRepository.java create mode 100644 src/main/java/se/urmo/hemhub/service/ProjectTaskService.java create mode 100644 src/main/java/se/urmo/hemhub/service/TaskDueService.java create mode 100644 src/main/java/se/urmo/hemhub/web/ProjectTaskController.java create mode 100644 src/main/resources/db/migration/V3__projects_tasks.sql create mode 100644 src/main/resources/db/migration/V4__task_household_link.sql create mode 100644 src/test/java/se/urmo/hemhub/integration/ProjectTaskControllerIT.java create mode 100644 src/test/java/se/urmo/hemhub/integration/TaskDueControllerIT.java diff --git a/src/main/java/se/urmo/hemhub/config/SchedulingConfig.java b/src/main/java/se/urmo/hemhub/config/SchedulingConfig.java new file mode 100644 index 0000000..05fef10 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/config/SchedulingConfig.java @@ -0,0 +1,18 @@ +package se.urmo.hemhub.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.time.Clock; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + + // Single source of time (makes scheduling & tests deterministic) + @Bean + public Clock systemClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/se/urmo/hemhub/domain/Project.java b/src/main/java/se/urmo/hemhub/domain/Project.java new file mode 100644 index 0000000..5cd9fed --- /dev/null +++ b/src/main/java/se/urmo/hemhub/domain/Project.java @@ -0,0 +1,30 @@ +package se.urmo.hemhub.domain; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "projects") +public class Project { + @Id private UUID id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "household_id") + private Household household; + + @Column(nullable=false, length=160) private String name; + @Column(columnDefinition="text") private String description; + @Column(name="created_at", nullable=false) private Instant createdAt = Instant.now(); + + protected Project() {} + public Project(UUID id, Household household, String name, String description) { + this.id=id; this.household=household; this.name=name; this.description=description; + } + + public UUID getId() { return id; } + public Household getHousehold() { return household; } + public String getName() { return name; } + public String getDescription() { return description; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/src/main/java/se/urmo/hemhub/domain/Task.java b/src/main/java/se/urmo/hemhub/domain/Task.java new file mode 100644 index 0000000..2a36574 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/domain/Task.java @@ -0,0 +1,89 @@ +package se.urmo.hemhub.domain; + +import jakarta.persistence.*; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "tasks") +public class Task { + public enum Priority { LOW, MEDIUM, HIGH } + public enum Status { OPEN, IN_PROGRESS, DONE } + + @Id + private UUID id; + + // Optional project + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id") + private Project project; + + // Required household (always set, even when project is null) + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "household_id") + private Household household; + + @Column(nullable=false, length=200) + private String title; + + @Column(columnDefinition="text") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable=false, length=12) + private Priority priority = Priority.MEDIUM; + + @Enumerated(EnumType.STRING) + @Column(nullable=false, length=12) + private Status status = Status.OPEN; + + @Column(name="due_date") + private LocalDate dueDate; + + @Column(name="assignee_sub", length=64) + private String assigneeSub; + + @Column(name="created_at", nullable=false) + private Instant createdAt = Instant.now(); + + protected Task() {} + + // Household-level task (no project) + public Task(UUID id, Household household, String title, String description, + Priority priority, Status status, LocalDate dueDate, String assigneeSub) { + this.id = id; + this.household = household; + this.title = title; + this.description = description; + if (priority != null) this.priority = priority; + if (status != null) this.status = status; + this.dueDate = dueDate; + this.assigneeSub = assigneeSub; + } + + // Project-level task (project implies household) + public Task(UUID id, Project project, String title, String description, + Priority priority, Status status, LocalDate dueDate, String assigneeSub) { + this(id, project.getHousehold(), title, description, priority, status, dueDate, assigneeSub); + this.project = project; + } + + public UUID getId() { return id; } + public Project getProject() { return project; } + public Household getHousehold() { return household; } + public String getTitle() { return title; } + public String getDescription() { return description; } + public Priority getPriority() { return priority; } + public Status getStatus() { return status; } + public LocalDate getDueDate() { return dueDate; } + public String getAssigneeSub() { return assigneeSub; } + public Instant getCreatedAt() { return createdAt; } + + public void setTitle(String s) { this.title = s; } + public void setDescription(String s) { this.description = s; } + public void setPriority(Priority p) { this.priority = p; } + public void setStatus(Status s) { this.status = s; } + public void setDueDate(LocalDate d) { this.dueDate = d; } + public void setAssigneeSub(String a) { this.assigneeSub = a; } +} diff --git a/src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java b/src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java new file mode 100644 index 0000000..64203c1 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/dto/ProjectTaskDtos.java @@ -0,0 +1,60 @@ +package se.urmo.hemhub.dto; + +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.util.*; + +public class ProjectTaskDtos { + // Projects + public record CreateProjectRequest( + @NotNull UUID householdId, + @NotBlank String name, + String description + ) {} + + public record ProjectResponse( + UUID id, + String name, + String description + ) {} + + // Project tasks (existing) + public record CreateTaskRequest( + @NotNull UUID projectId, + @NotBlank String title, + String description, + @Pattern(regexp="LOW|MEDIUM|HIGH") String priority, + @Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status, + LocalDate dueDate, + String assigneeSub + ) {} + + // Household tasks (new) – no projectId + public record CreateHouseholdTaskRequest( + @NotBlank String title, + String description, + @Pattern(regexp="LOW|MEDIUM|HIGH") String priority, + @Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status, + LocalDate dueDate, + String assigneeSub + ) {} + + public record UpdateTaskRequest( + String title, + String description, + @Pattern(regexp="LOW|MEDIUM|HIGH") String priority, + @Pattern(regexp="OPEN|IN_PROGRESS|DONE") String status, + LocalDate dueDate, + String assigneeSub + ) {} + + public record TaskResponse( + UUID id, + String title, + String description, + String priority, + String status, + LocalDate dueDate, + String assigneeSub + ) {} +} diff --git a/src/main/java/se/urmo/hemhub/notify/LogNotificationService.java b/src/main/java/se/urmo/hemhub/notify/LogNotificationService.java new file mode 100644 index 0000000..0a8d3b4 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/notify/LogNotificationService.java @@ -0,0 +1,31 @@ +package se.urmo.hemhub.notify; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import se.urmo.hemhub.domain.Task; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class LogNotificationService implements NotificationService { + + @Override + public void notifyTasksDueTomorrow(List tasks) { + if (tasks.isEmpty()) { + log.info("[reminder] No tasks due tomorrow."); + return; + } + var summary = tasks.stream() + .map(t -> String.format("%s (prio=%s, status=%s, household=%s, project=%s, assignee=%s)", + t.getTitle(), + t.getPriority(), + t.getStatus(), + t.getHousehold().getId(), + t.getProject() != null ? t.getProject().getId() : "—", + t.getAssigneeSub() != null ? t.getAssigneeSub() : "—")) + .collect(Collectors.joining("; ")); + log.info("[reminder] Tasks due tomorrow: {}", summary); + } +} diff --git a/src/main/java/se/urmo/hemhub/notify/NotificationService.java b/src/main/java/se/urmo/hemhub/notify/NotificationService.java new file mode 100644 index 0000000..eac6363 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/notify/NotificationService.java @@ -0,0 +1,9 @@ +package se.urmo.hemhub.notify; + +import se.urmo.hemhub.domain.Task; + +import java.util.List; + +public interface NotificationService { + void notifyTasksDueTomorrow(List tasks); +} diff --git a/src/main/java/se/urmo/hemhub/repo/ProjectRepository.java b/src/main/java/se/urmo/hemhub/repo/ProjectRepository.java new file mode 100644 index 0000000..3d29da7 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/repo/ProjectRepository.java @@ -0,0 +1,10 @@ +package se.urmo.hemhub.repo; + +import org.springframework.data.jpa.repository.JpaRepository; +import se.urmo.hemhub.domain.Project; +import java.util.*; + +public interface ProjectRepository extends JpaRepository { + List findByHouseholdId(UUID householdId); + boolean existsByIdAndHouseholdId(UUID projectId, UUID householdId); +} diff --git a/src/main/java/se/urmo/hemhub/repo/TaskRepository.java b/src/main/java/se/urmo/hemhub/repo/TaskRepository.java new file mode 100644 index 0000000..ecf44d2 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/repo/TaskRepository.java @@ -0,0 +1,36 @@ +package se.urmo.hemhub.repo; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import se.urmo.hemhub.domain.Task; + +import java.time.LocalDate; +import java.util.*; + +public interface TaskRepository extends JpaRepository { + + List findByProjectId(UUID projectId); + boolean existsByIdAndProjectId(UUID taskId, UUID projectId); + + // Household-level tasks (no project) + List findByHouseholdIdAndProjectIsNull(UUID householdId); + + // All tasks due on a given day, excluding a particular status (e.g., DONE) + List findByDueDateAndStatusNot(LocalDate date, Task.Status status); + + // Only tasks for households where the given user is a member (for the /me view) + @Query(""" + select t + from Task t + where t.dueDate = :date + and t.status <> :excludeStatus + and exists ( + select 1 from HouseholdMember m + where m.household = t.household and m.userSub = :userSub + ) + """) + List findDueByDateForUser(@Param("date") LocalDate date, + @Param("excludeStatus") Task.Status excludeStatus, + @Param("userSub") String userSub); +} diff --git a/src/main/java/se/urmo/hemhub/service/ProjectTaskService.java b/src/main/java/se/urmo/hemhub/service/ProjectTaskService.java new file mode 100644 index 0000000..89f0312 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/service/ProjectTaskService.java @@ -0,0 +1,108 @@ +package se.urmo.hemhub.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import se.urmo.hemhub.domain.*; +import se.urmo.hemhub.repo.*; + +import java.time.LocalDate; +import java.util.*; +import java.util.function.Consumer; + +@Service +@RequiredArgsConstructor +public class ProjectTaskService { + private final HouseholdRepository households; + private final HouseholdMemberRepository members; + private final ProjectRepository projects; + private final TaskRepository tasks; + + private void ensureMember(UUID householdId, String sub) { + if (!members.existsByHouseholdIdAndUserSub(householdId, sub)) { + throw new SecurityException("Forbidden: not a household member"); + } + } + + private void ensureOwner(UUID householdId, String sub) { + var m = members.findByHouseholdIdAndUserSub(householdId, sub) + .orElseThrow(() -> new SecurityException("Not a member")); + if (m.getRole() != HouseholdMember.Role.OWNER) { + throw new SecurityException("Forbidden: owner only"); + } + } + + // -------- Projects -------- + @Transactional + public Project createProject(UUID householdId, String requesterSub, String name, String description) { + ensureOwner(householdId, requesterSub); + var h = households.findById(householdId).orElseThrow(); + var p = new Project(UUID.randomUUID(), h, name, description); + return projects.save(p); + } + + @Transactional(readOnly = true) + public List listProjects(UUID householdId, String requesterSub) { + ensureMember(householdId, requesterSub); + return projects.findByHouseholdId(householdId); + } + + @Transactional + public void deleteProject(UUID projectId, String requesterSub) { + var p = projects.findById(projectId).orElseThrow(); + ensureOwner(p.getHousehold().getId(), requesterSub); + projects.delete(p); // cascades tasks for that project + } + + // -------- Tasks (project-scoped) -------- + @Transactional + public Task createTask(UUID projectId, String requesterSub, String title, String description, + Task.Priority priority, Task.Status status, LocalDate dueDate, String assigneeSub) { + var p = projects.findById(projectId).orElseThrow(); + var householdId = p.getHousehold().getId(); + ensureMember(householdId, requesterSub); + var t = new Task(UUID.randomUUID(), p, title, description, priority, status, dueDate, assigneeSub); + return tasks.save(t); + } + + @Transactional(readOnly = true) + public List listTasks(UUID projectId, String requesterSub) { + var p = projects.findById(projectId).orElseThrow(); + ensureMember(p.getHousehold().getId(), requesterSub); + return tasks.findByProjectId(projectId); + } + + // -------- Tasks (household-scoped, no project) -------- + @Transactional + public Task createHouseholdTask(UUID householdId, String requesterSub, + String title, String description, + Task.Priority priority, Task.Status status, + LocalDate dueDate, String assigneeSub) { + ensureMember(householdId, requesterSub); + var h = households.findById(householdId).orElseThrow(); + var t = new Task(UUID.randomUUID(), h, title, description, priority, status, dueDate, assigneeSub); + return tasks.save(t); + } + + @Transactional(readOnly = true) + public List listHouseholdTasks(UUID householdId, String requesterSub) { + ensureMember(householdId, requesterSub); + return tasks.findByHouseholdIdAndProjectIsNull(householdId); + } + + // -------- Common updates -------- + @Transactional + public Task updateTask(UUID taskId, String requesterSub, Consumer applier) { + var t = tasks.findById(taskId).orElseThrow(); + var householdId = t.getHousehold().getId(); + ensureMember(householdId, requesterSub); + applier.accept(t); + return tasks.save(t); + } + + @Transactional(readOnly = true) + public java.util.List tasksDueTomorrowForUser(String userSub) { + var tomorrow = LocalDate.now(java.time.Clock.systemDefaultZone()).plusDays(1); + return tasks.findDueByDateForUser(tomorrow, Task.Status.DONE, userSub); + } +} diff --git a/src/main/java/se/urmo/hemhub/service/TaskDueService.java b/src/main/java/se/urmo/hemhub/service/TaskDueService.java new file mode 100644 index 0000000..3c8f6ea --- /dev/null +++ b/src/main/java/se/urmo/hemhub/service/TaskDueService.java @@ -0,0 +1,38 @@ +package se.urmo.hemhub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import se.urmo.hemhub.domain.Task; +import se.urmo.hemhub.notify.NotificationService; +import se.urmo.hemhub.repo.TaskRepository; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskDueService { + + private final TaskRepository tasks; + private final NotificationService notificationService; + private final Clock clock; + + /** + * Runs daily at 08:00 by default. + * CRON can be overridden via property: hemhub.schedule.reminders.cron + */ + @Scheduled(cron = "${hemhub.schedule.reminders.cron:0 0 8 * * *}") + public void sendRemindersForTomorrow() { + List due = findTasksDueTomorrow(); + notificationService.notifyTasksDueTomorrow(due); + } + + public List findTasksDueTomorrow() { + LocalDate tomorrow = LocalDate.now(clock).plusDays(1); + return tasks.findByDueDateAndStatusNot(tomorrow, Task.Status.DONE); + } +} diff --git a/src/main/java/se/urmo/hemhub/web/ProjectTaskController.java b/src/main/java/se/urmo/hemhub/web/ProjectTaskController.java new file mode 100644 index 0000000..4c55489 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/web/ProjectTaskController.java @@ -0,0 +1,111 @@ +package se.urmo.hemhub.web; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import se.urmo.hemhub.domain.Task; +import se.urmo.hemhub.dto.ProjectTaskDtos.*; +import se.urmo.hemhub.service.ProjectTaskService; + +import java.util.*; +import static java.util.stream.Collectors.toList; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class ProjectTaskController { + private final ProjectTaskService svc; + + private static String sub(Authentication auth) { return ((Jwt)auth.getPrincipal()).getSubject(); } + + // -------- Projects -------- + + @PostMapping("/projects") + public ProjectResponse createProject(@Valid @RequestBody CreateProjectRequest req, Authentication auth) { + var p = svc.createProject(req.householdId(), sub(auth), req.name(), req.description()); + return new ProjectResponse(p.getId(), p.getName(), p.getDescription()); + } + + @GetMapping("/households/{householdId}/projects") + public List listProjects(@PathVariable UUID householdId, Authentication auth) { + return svc.listProjects(householdId, sub(auth)).stream() + .map(p -> new ProjectResponse(p.getId(), p.getName(), p.getDescription())) + .collect(toList()); + } + + @DeleteMapping("/projects/{projectId}") + public void deleteProject(@PathVariable UUID projectId, Authentication auth) { + svc.deleteProject(projectId, sub(auth)); + } + + // -------- Tasks (project-scoped) -------- + + @PostMapping("/tasks") + public TaskResponse createTask(@Valid @RequestBody CreateTaskRequest req, Authentication auth) { + var t = svc.createTask( + req.projectId(), sub(auth), req.title(), req.description(), + req.priority() == null ? Task.Priority.MEDIUM : Task.Priority.valueOf(req.priority()), + req.status() == null ? Task.Status.OPEN : Task.Status.valueOf(req.status()), + req.dueDate(), req.assigneeSub() + ); + return toDto(t); + } + + @GetMapping("/projects/{projectId}/tasks") + public List listTasks(@PathVariable UUID projectId, Authentication auth) { + return svc.listTasks(projectId, sub(auth)).stream().map(this::toDto).collect(toList()); + } + + // -------- Tasks (household-scoped) -------- + + @PostMapping("/households/{householdId}/tasks") + public TaskResponse createHouseholdTask(@PathVariable UUID householdId, + @Valid @RequestBody CreateHouseholdTaskRequest req, + Authentication auth) { + var t = svc.createHouseholdTask( + householdId, sub(auth), req.title(), req.description(), + req.priority() == null ? Task.Priority.MEDIUM : Task.Priority.valueOf(req.priority()), + req.status() == null ? Task.Status.OPEN : Task.Status.valueOf(req.status()), + req.dueDate(), req.assigneeSub() + ); + return toDto(t); + } + + @GetMapping("/households/{householdId}/tasks") + public List listHouseholdTasks(@PathVariable UUID householdId, Authentication auth) { + return svc.listHouseholdTasks(householdId, sub(auth)).stream().map(this::toDto).collect(toList()); + } + + // -------- Task update (common) -------- + + @PatchMapping("/tasks/{taskId}") + public TaskResponse updateTask(@PathVariable UUID taskId, + @Valid @RequestBody UpdateTaskRequest req, + Authentication auth) { + var t = svc.updateTask(taskId, sub(auth), entity -> { + if (req.title() != null) entity.setTitle(req.title()); + if (req.description() != null) entity.setDescription(req.description()); + if (req.priority() != null) entity.setPriority(Task.Priority.valueOf(req.priority())); + if (req.status() != null) entity.setStatus(Task.Status.valueOf(req.status())); + if (req.dueDate() != null) entity.setDueDate(req.dueDate()); + if (req.assigneeSub() != null) entity.setAssigneeSub(req.assigneeSub()); + }); + return toDto(t); + } + + private TaskResponse toDto(Task t) { + return new TaskResponse(t.getId(), t.getTitle(), t.getDescription(), + t.getPriority().name(), t.getStatus().name(), t.getDueDate(), t.getAssigneeSub()); + } + + @GetMapping("/tasks/due/tomorrow") + public List tasksDueTomorrow(Authentication auth) { + var todayPlus1 = java.time.LocalDate.now(); + // Service method not required; use repository through a lightweight orchestrator or reuse a new service + // To keep controllers thin, we’ll delegate to svc via a tiny method (add this method in ProjectTaskService if you prefer). + var list = svc.tasksDueTomorrowForUser(sub(auth)); + return list.stream().map(this::toDto).toList(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b3950c..3d1dc84 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,3 +16,9 @@ spring: resourceserver: jwt: jwk-set-uri: http://keycloak:8081/realms/hemhub/protocol/openid-connect/certs +hemhub: + schedule: + reminders: + # Default: every day at 08:00 + cron: "0 0 8 * * *" + diff --git a/src/main/resources/db/migration/V3__projects_tasks.sql b/src/main/resources/db/migration/V3__projects_tasks.sql new file mode 100644 index 0000000..99f703f --- /dev/null +++ b/src/main/resources/db/migration/V3__projects_tasks.sql @@ -0,0 +1,30 @@ +-- H2 + Postgres friendly + +CREATE TABLE projects ( + id UUID PRIMARY KEY, + household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE tasks ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + description TEXT, + priority VARCHAR(12) NOT NULL, -- LOW/MEDIUM/HIGH + status VARCHAR(12) NOT NULL, -- OPEN/IN_PROGRESS/DONE + due_date DATE, + assignee_sub VARCHAR(64), -- optional: user SUB of a member + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_projects_household ON projects(household_id); +CREATE INDEX idx_tasks_project ON tasks(project_id); + +ALTER TABLE tasks + ADD CONSTRAINT chk_task_priority CHECK (priority IN ('LOW','MEDIUM','HIGH')); + +ALTER TABLE tasks + ADD CONSTRAINT chk_task_status CHECK (status IN ('OPEN','IN_PROGRESS','DONE')); diff --git a/src/main/resources/db/migration/V4__task_household_link.sql b/src/main/resources/db/migration/V4__task_household_link.sql new file mode 100644 index 0000000..e3be607 --- /dev/null +++ b/src/main/resources/db/migration/V4__task_household_link.sql @@ -0,0 +1,28 @@ +-- V4: Allow tasks to belong directly to a household (project optional) +ALTER TABLE tasks + ADD COLUMN household_id UUID; + +ALTER TABLE tasks + ADD CONSTRAINT fk_tasks_household + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE; + +ALTER TABLE tasks + ALTER COLUMN project_id DROP NOT NULL; + +-- Create a temporary mapping table (drops harmlessly if exists) +CREATE TABLE IF NOT EXISTS _tmp_project_household_map AS +SELECT p.id AS project_id, p.household_id AS household_id +FROM projects p; + +-- Update tasks where household_id is null and project_id matches +UPDATE tasks t +SET household_id = ( + SELECT household_id FROM _tmp_project_household_map m WHERE m.project_id = t.project_id +) +WHERE t.household_id IS NULL AND t.project_id IS NOT NULL; + +-- Clean up temporary table (ignore if not exists) +DROP TABLE IF EXISTS _tmp_project_household_map; + +ALTER TABLE tasks + ALTER COLUMN household_id SET NOT NULL; diff --git a/src/test/http/hemhub-api.http b/src/test/http/hemhub-api.http index 6121dd6..bf2bdb2 100644 --- a/src/test/http/hemhub-api.http +++ b/src/test/http/hemhub-api.http @@ -28,7 +28,7 @@ Authorization: Bearer {{token}} "name": "Familjen Andersson" } -> {% client.global.set("householdId", JSON.parse(response.body).id); %} +> {% client.global.set("householdId", response.body.id); %} ### 5. List my households GET http://localhost:8080/api/v1/households @@ -53,3 +53,68 @@ Authorization: Bearer {{token}} ### 8. List members again (should now include Ulf) GET http://localhost:8080/api/v1/households/{{householdId}}/members Authorization: Bearer {{token}} + +### 9) Create project (OWNER only) +POST http://localhost:8080/api/v1/projects +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "householdId": "{{householdId}}", + "name": "Sovrumsrenovering", + "description": "Måla, golv, el" +} + +> {% client.global.set("projectId", response.body.id); %} + +### 10) List projects in household +GET http://localhost:8080/api/v1/households/{{householdId}}/projects +Authorization: Bearer {{token}} + +### 11) Create task +POST http://localhost:8080/api/v1/tasks +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "projectId": "{{projectId}}", + "title": "Damsug soffan", + "priority": "LOW" +} + +> {% client.global.set("taskId", response.body.id); %} + +### 12) List tasks +GET http://localhost:8080/api/v1/projects/{{projectId}}/tasks +Authorization: Bearer {{token}} + +### 13) Update task +PATCH http://localhost:8080/api/v1/tasks/{{taskId}} +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "status": "DONE" +} + +### 14) Create a household-level task (no project) +POST http://localhost:8080/api/v1/households/{{householdId}}/tasks +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "title": "Buy milk", + "priority": "HIGH" +} + +> {% client.global.set("taskId", response.body.id); %} + +### 15) List household-level tasks +GET http://localhost:8080/api/v1/households/{{householdId}}/tasks +Authorization: Bearer {{token}} + +### 16) My tasks due tomorrow +GET http://localhost:8080/api/v1/tasks/due/tomorrow +Authorization: Bearer {{token}} + + diff --git a/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java b/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java index f288377..36f7b13 100644 --- a/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java +++ b/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java @@ -1,32 +1,36 @@ package se.urmo.hemhub.integration; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.util.UUID; +import java.util.List; +import java.util.Map; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class HouseholdControllerIT { - @Autowired MockMvc mvc; + @Autowired + MockMvc mvc; @Test void create_household_and_list_for_user() throws Exception { var jwtUser = jwt().jwt(j -> { j.subject("sub-user-1"); - j.claim("email","u1@example.com"); - j.claim("preferred_username","u1"); - j.claim("realm_access", java.util.Map.of("roles", java.util.List.of("OWNER","MEMBER"))); + j.claim("email", "u1@example.com"); + j.claim("preferred_username", "u1"); + j.claim("realm_access", Map.of("roles", List.of("OWNER", "MEMBER"))); }); // create @@ -45,8 +49,16 @@ class HouseholdControllerIT { @Test void add_member_requires_owner() throws Exception { - var owner = jwt().jwt(j -> { j.subject("owner-sub"); j.claim("email","o@ex.com"); j.claim("preferred_username","owner"); }); - var other = jwt().jwt(j -> { j.subject("other-sub"); j.claim("email","x@ex.com"); j.claim("preferred_username","x"); }); + var owner = jwt().jwt(j -> { + j.subject("owner-sub"); + j.claim("email", "o@ex.com"); + j.claim("preferred_username", "owner"); + }); + var other = jwt().jwt(j -> { + j.subject("other-sub"); + j.claim("email", "x@ex.com"); + j.claim("preferred_username", "x"); + }); // create household as owner var res = mvc.perform(post("/api/v1/households").with(owner) @@ -55,7 +67,7 @@ class HouseholdControllerIT { var id = com.jayway.jsonpath.JsonPath.read(res.getResponse().getContentAsString(), "$.id"); // cannot add as non-owner - mvc.perform(post("/api/v1/households/"+id+"/members").with(other) + mvc.perform(post("/api/v1/households/" + id + "/members").with(other) .contentType("application/json") .content("{\"userSub\":\"u2\",\"role\":\"MEMBER\"}")) .andExpect(status().isForbidden()); diff --git a/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java b/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java index aba1dbe..6be6894 100644 --- a/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java +++ b/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java @@ -1,24 +1,26 @@ package se.urmo.hemhub.integration; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MockMvc; import java.util.Map; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class MeControllerBranchesIT { - @Autowired MockMvc mvc; + @Autowired + MockMvc mvc; @Test void me_withoutRealmAccess_rolesEmpty() throws Exception { diff --git a/src/test/java/se/urmo/hemhub/integration/MeControllerIT.java b/src/test/java/se/urmo/hemhub/integration/MeControllerIT.java index e73a57e..946808e 100644 --- a/src/test/java/se/urmo/hemhub/integration/MeControllerIT.java +++ b/src/test/java/se/urmo/hemhub/integration/MeControllerIT.java @@ -1,18 +1,19 @@ package se.urmo.hemhub.integration; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Map; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc diff --git a/src/test/java/se/urmo/hemhub/integration/ProjectTaskControllerIT.java b/src/test/java/se/urmo/hemhub/integration/ProjectTaskControllerIT.java new file mode 100644 index 0000000..d268928 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/integration/ProjectTaskControllerIT.java @@ -0,0 +1,70 @@ +package se.urmo.hemhub.integration; + +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ProjectTaskControllerIT { + + @Autowired MockMvc mvc; + + @Test + void project_and_tasks_happy_path() throws Exception { + var owner = jwt().jwt(j -> { + j.subject("owner-sub"); + j.claim("email","o@ex.com"); + j.claim("preferred_username","owner"); + }); + + // Create household first (from Iteration 2 endpoint) + var hRes = mvc.perform(post("/api/v1/households").with(owner) + .contentType("application/json").content("{\"name\":\"H1\"}")) + .andExpect(status().isOk()).andReturn(); + var householdId = JsonPath.read(hRes.getResponse().getContentAsString(), "$.id"); + + // Create project + var pRes = mvc.perform(post("/api/v1/projects").with(owner) + .contentType("application/json").content("{\"householdId\":\""+householdId+"\",\"name\":\"Sovrumsrenovering\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.id").exists()).andReturn(); + var projectId = com.jayway.jsonpath.JsonPath.read(pRes.getResponse().getContentAsString(), "$.id"); + + // List projects (should contain one) + mvc.perform(get("/api/v1/households/"+householdId+"/projects").with(owner)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Sovrumsrenovering")); + + // Create a task + mvc.perform(post("/api/v1/tasks").with(owner) + .contentType("application/json") + .content("{\"projectId\":\""+projectId+"\",\"title\":\"Damsug soffan\",\"priority\":\"LOW\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Damsug soffan")); + + // List tasks + mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].priority").value("LOW")); + + // Update task status + var list = mvc.perform(get("/api/v1/projects/"+projectId+"/tasks").with(owner)) + .andReturn().getResponse().getContentAsString(); + var taskId = com.jayway.jsonpath.JsonPath.read(list, "$[0].id"); + + mvc.perform(patch("/api/v1/tasks/"+taskId).with(owner) + .contentType("application/json") + .content("{\"status\":\"DONE\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DONE")); + } +} diff --git a/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java b/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java index 3025e8b..0a880f0 100644 --- a/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java +++ b/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java @@ -1,14 +1,15 @@ package se.urmo.hemhub.integration; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.beans.factory.annotation.Autowired; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc diff --git a/src/test/java/se/urmo/hemhub/integration/TaskDueControllerIT.java b/src/test/java/se/urmo/hemhub/integration/TaskDueControllerIT.java new file mode 100644 index 0000000..db34f71 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/integration/TaskDueControllerIT.java @@ -0,0 +1,53 @@ +package se.urmo.hemhub.integration; + +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TaskDueControllerIT { + + @Autowired + MockMvc mvc; + + @Test + void list_tasks_due_tomorrow_returns_results_for_member() throws Exception { + var user = jwt().jwt(j -> { + j.subject("sub-user"); + j.claim("email", "u@ex.com"); + j.claim("preferred_username", "u"); + }); + + // Create household + var hh = mvc.perform(post("/api/v1/households").with(user) + .contentType("application/json").content("{\"name\":\"H1\"}")) + .andExpect(status().isOk()).andReturn(); + var householdId = JsonPath.read(hh.getResponse().getContentAsString(), "$.id"); + + // Create a household-level task due tomorrow + var tomorrow = LocalDate.now().plusDays(1); + mvc.perform(post("/api/v1/households/" + householdId + "/tasks").with(user) + .contentType("application/json") + .content("{\"title\":\"Påminn mig\",\"priority\":\"MEDIUM\",\"dueDate\":\"" + tomorrow + "\"}")) + .andExpect(status().isOk()); + + // Expect the new endpoint to include it + mvc.perform(get("/api/v1/tasks/due/tomorrow").with(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("Påminn mig")); + } +}