From ad23616c7b77e15a915fb086651860b63b055276 Mon Sep 17 00:00:00 2001 From: Achim Rohn Date: Sat, 16 May 2026 11:41:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20implement=20domain=20=E2=80=94?= =?UTF-8?q?=20users,=20projects,=20time=20entries,=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User, Project, TimeEntry JPA entities with SLF4J logging in services - Native SQL aggregate queries: per-day (weekly) and per-project (monthly) both using COALESCE(project_rate, user_rate) for cost calculation - ReportService parses ISO week/month strings, fills zero-day gaps, computes timeBudgetPct / costBudgetPct / breached per project - ProjectService piggybacks on aggregateAllByProject for budget status on GET /api/projects (no N+1) - GlobalExceptionHandler maps validation errors → 400 ProblemDetail, EntityNotFoundException → 404, IllegalArgumentException → 400 - Lombok on pom.xml (optional, used for @Getter/@Setter/@NoArgsConstructor) Co-Authored-By: Claude Opus 4.7 --- backend/pom.xml | 5 + .../app/common/GlobalExceptionHandler.java | 44 +++++++ .../java/com/example/app/project/Project.java | 30 +++++ .../app/project/ProjectController.java | 30 +++++ .../com/example/app/project/ProjectDtos.java | 39 ++++++ .../app/project/ProjectRepository.java | 5 + .../example/app/project/ProjectService.java | 56 +++++++++ .../example/app/report/ReportController.java | 26 ++++ .../com/example/app/report/ReportDtos.java | 26 ++++ .../com/example/app/report/ReportService.java | 114 ++++++++++++++++++ .../java/com/example/app/time/TimeEntry.java | 35 ++++++ .../example/app/time/TimeEntryController.java | 48 ++++++++ .../com/example/app/time/TimeEntryDtos.java | 41 +++++++ .../example/app/time/TimeEntryRepository.java | 95 +++++++++++++++ .../example/app/time/TimeEntryService.java | 86 +++++++++++++ .../main/java/com/example/app/user/User.java | 24 ++++ .../com/example/app/user/UserController.java | 23 ++++ .../java/com/example/app/user/UserDtos.java | 11 ++ .../com/example/app/user/UserRepository.java | 5 + .../com/example/app/user/UserService.java | 21 ++++ 20 files changed, 764 insertions(+) create mode 100644 backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/example/app/project/Project.java create mode 100644 backend/src/main/java/com/example/app/project/ProjectController.java create mode 100644 backend/src/main/java/com/example/app/project/ProjectDtos.java create mode 100644 backend/src/main/java/com/example/app/project/ProjectRepository.java create mode 100644 backend/src/main/java/com/example/app/project/ProjectService.java create mode 100644 backend/src/main/java/com/example/app/report/ReportController.java create mode 100644 backend/src/main/java/com/example/app/report/ReportDtos.java create mode 100644 backend/src/main/java/com/example/app/report/ReportService.java create mode 100644 backend/src/main/java/com/example/app/time/TimeEntry.java create mode 100644 backend/src/main/java/com/example/app/time/TimeEntryController.java create mode 100644 backend/src/main/java/com/example/app/time/TimeEntryDtos.java create mode 100644 backend/src/main/java/com/example/app/time/TimeEntryRepository.java create mode 100644 backend/src/main/java/com/example/app/time/TimeEntryService.java create mode 100644 backend/src/main/java/com/example/app/user/User.java create mode 100644 backend/src/main/java/com/example/app/user/UserController.java create mode 100644 backend/src/main/java/com/example/app/user/UserDtos.java create mode 100644 backend/src/main/java/com/example/app/user/UserRepository.java create mode 100644 backend/src/main/java/com/example/app/user/UserService.java diff --git a/backend/pom.xml b/backend/pom.xml index 5704873..47431a8 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -46,6 +46,11 @@ postgresql runtime + + org.projectlombok + lombok + true + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java b/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..50a9511 --- /dev/null +++ b/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package com.example.app.common; + +import jakarta.persistence.EntityNotFoundException; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setType(URI.create("urn:problem:validation-error")); + pd.setTitle("Validation failed"); + pd.setProperty( + "fields", + ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .toList()); + return pd; + } + + @ExceptionHandler(EntityNotFoundException.class) + public ProblemDetail handleNotFound(EntityNotFoundException ex) { + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND); + pd.setType(URI.create("urn:problem:not-found")); + pd.setTitle("Resource not found"); + pd.setDetail(ex.getMessage()); + return pd; + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArg(IllegalArgumentException ex) { + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setType(URI.create("urn:problem:bad-request")); + pd.setTitle("Bad request"); + pd.setDetail(ex.getMessage()); + return pd; + } +} diff --git a/backend/src/main/java/com/example/app/project/Project.java b/backend/src/main/java/com/example/app/project/Project.java new file mode 100644 index 0000000..6aafa8e --- /dev/null +++ b/backend/src/main/java/com/example/app/project/Project.java @@ -0,0 +1,30 @@ +package com.example.app.project; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "projects") +@Getter +@Setter +@NoArgsConstructor +public class Project { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int timeBudgetMinutes; + + @Column(nullable = false) + private int costBudgetCents; + + /** Null means fall back to the logging user's defaultHourlyRateCents. */ + @Column private Integer hourlyRateCents; +} diff --git a/backend/src/main/java/com/example/app/project/ProjectController.java b/backend/src/main/java/com/example/app/project/ProjectController.java new file mode 100644 index 0000000..f3fa058 --- /dev/null +++ b/backend/src/main/java/com/example/app/project/ProjectController.java @@ -0,0 +1,30 @@ +package com.example.app.project; + +import com.example.app.project.ProjectDtos.CreateProjectRequest; +import com.example.app.project.ProjectDtos.ProjectResponse; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/projects") +public class ProjectController { + + private final ProjectService projectService; + + public ProjectController(ProjectService projectService) { + this.projectService = projectService; + } + + @GetMapping + public List list() { + return projectService.findAll(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) { + return projectService.create(req); + } +} diff --git a/backend/src/main/java/com/example/app/project/ProjectDtos.java b/backend/src/main/java/com/example/app/project/ProjectDtos.java new file mode 100644 index 0000000..b6cfb4b --- /dev/null +++ b/backend/src/main/java/com/example/app/project/ProjectDtos.java @@ -0,0 +1,39 @@ +package com.example.app.project; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + +public class ProjectDtos { + + public record CreateProjectRequest( + @NotBlank String name, + @Min(1) int timeBudgetMinutes, + @PositiveOrZero int costBudgetCents, + @PositiveOrZero Integer hourlyRateCents) {} + + public record ProjectResponse( + Long id, + String name, + int timeBudgetMinutes, + int costBudgetCents, + Integer hourlyRateCents, + long minutesLogged, + long costLoggedCents, + boolean timeExceeded, + boolean costExceeded) { + + public static ProjectResponse from(Project p, long minutesLogged, long costLoggedCents) { + return new ProjectResponse( + p.getId(), + p.getName(), + p.getTimeBudgetMinutes(), + p.getCostBudgetCents(), + p.getHourlyRateCents(), + minutesLogged, + costLoggedCents, + minutesLogged > p.getTimeBudgetMinutes(), + costLoggedCents > p.getCostBudgetCents()); + } + } +} diff --git a/backend/src/main/java/com/example/app/project/ProjectRepository.java b/backend/src/main/java/com/example/app/project/ProjectRepository.java new file mode 100644 index 0000000..2515160 --- /dev/null +++ b/backend/src/main/java/com/example/app/project/ProjectRepository.java @@ -0,0 +1,5 @@ +package com.example.app.project; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectRepository extends JpaRepository {} diff --git a/backend/src/main/java/com/example/app/project/ProjectService.java b/backend/src/main/java/com/example/app/project/ProjectService.java new file mode 100644 index 0000000..2dcbd4a --- /dev/null +++ b/backend/src/main/java/com/example/app/project/ProjectService.java @@ -0,0 +1,56 @@ +package com.example.app.project; + +import com.example.app.project.ProjectDtos.CreateProjectRequest; +import com.example.app.project.ProjectDtos.ProjectResponse; +import com.example.app.time.TimeEntryRepository; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectService { + + private final ProjectRepository projectRepository; + private final TimeEntryRepository timeEntryRepository; + + public ProjectService( + ProjectRepository projectRepository, TimeEntryRepository timeEntryRepository) { + this.projectRepository = projectRepository; + this.timeEntryRepository = timeEntryRepository; + } + + @Transactional(readOnly = true) + public List findAll() { + List projects = projectRepository.findAll(); + + // one aggregate query for totals across all entries + Map totals = new HashMap<>(); + for (Object[] row : timeEntryRepository.aggregateAllByProject()) { + long projectId = ((Number) row[0]).longValue(); + long minutes = ((Number) row[1]).longValue(); + long cost = ((Number) row[2]).longValue(); + totals.put(projectId, new long[] {minutes, cost}); + } + + return projects.stream() + .map( + p -> { + long[] t = totals.getOrDefault(p.getId(), new long[] {0, 0}); + return ProjectResponse.from(p, t[0], t[1]); + }) + .toList(); + } + + @Transactional + public ProjectResponse create(CreateProjectRequest req) { + Project p = new Project(); + p.setName(req.name()); + p.setTimeBudgetMinutes(req.timeBudgetMinutes()); + p.setCostBudgetCents(req.costBudgetCents()); + p.setHourlyRateCents(req.hourlyRateCents()); + Project saved = projectRepository.save(p); + return ProjectResponse.from(saved, 0, 0); + } +} diff --git a/backend/src/main/java/com/example/app/report/ReportController.java b/backend/src/main/java/com/example/app/report/ReportController.java new file mode 100644 index 0000000..4573e5e --- /dev/null +++ b/backend/src/main/java/com/example/app/report/ReportController.java @@ -0,0 +1,26 @@ +package com.example.app.report; + +import com.example.app.report.ReportDtos.MonthlyReport; +import com.example.app.report.ReportDtos.WeeklyReport; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/reports") +public class ReportController { + + private final ReportService reportService; + + public ReportController(ReportService reportService) { + this.reportService = reportService; + } + + @GetMapping("/weekly") + public WeeklyReport weekly(@RequestParam Long userId, @RequestParam String week) { + return reportService.weeklyReport(userId, week); + } + + @GetMapping("/monthly") + public MonthlyReport monthly(@RequestParam Long userId, @RequestParam String month) { + return reportService.monthlyReport(userId, month); + } +} diff --git a/backend/src/main/java/com/example/app/report/ReportDtos.java b/backend/src/main/java/com/example/app/report/ReportDtos.java new file mode 100644 index 0000000..58cda3f --- /dev/null +++ b/backend/src/main/java/com/example/app/report/ReportDtos.java @@ -0,0 +1,26 @@ +package com.example.app.report; + +import java.time.LocalDate; +import java.util.List; + +public class ReportDtos { + + public record DayReport(LocalDate date, long totalMinutes, long totalCostCents) {} + + public record WeeklyReport( + List days, long weekTotalMinutes, long weekTotalCostCents) {} + + public record ProjectMonthReport( + Long projectId, + String name, + long minutes, + long costCents, + int timeBudgetMinutes, + int costBudgetCents, + double timeBudgetPct, + double costBudgetPct, + boolean breached) {} + + public record MonthlyReport( + List projects, long monthTotalMinutes, long monthTotalCostCents) {} +} diff --git a/backend/src/main/java/com/example/app/report/ReportService.java b/backend/src/main/java/com/example/app/report/ReportService.java new file mode 100644 index 0000000..c839f63 --- /dev/null +++ b/backend/src/main/java/com/example/app/report/ReportService.java @@ -0,0 +1,114 @@ +package com.example.app.report; + +import com.example.app.report.ReportDtos.DayReport; +import com.example.app.report.ReportDtos.MonthlyReport; +import com.example.app.report.ReportDtos.ProjectMonthReport; +import com.example.app.report.ReportDtos.WeeklyReport; +import com.example.app.time.TimeEntryRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.IsoFields; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class ReportService { + + private static final DateTimeFormatter ISO_WEEK_FORMATTER = + DateTimeFormatter.ofPattern("YYYY-'W'ww"); + + private final TimeEntryRepository timeEntryRepository; + + public ReportService(TimeEntryRepository timeEntryRepository) { + this.timeEntryRepository = timeEntryRepository; + } + + public WeeklyReport weeklyReport(Long userId, String isoWeek) { + LocalDate monday = parseIsoWeekToMonday(isoWeek); + LocalDate sunday = monday.plusDays(6); + + Map byDay = + timeEntryRepository.aggregateByDay(userId, monday, sunday).stream() + .collect( + Collectors.toMap( + row -> ((java.sql.Date) row[0]).toLocalDate(), + row -> + new long[] {((Number) row[1]).longValue(), ((Number) row[2]).longValue()})); + + List days = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + LocalDate day = monday.plusDays(i); + long[] totals = byDay.getOrDefault(day, new long[] {0, 0}); + days.add(new DayReport(day, totals[0], totals[1])); + } + + long weekMinutes = days.stream().mapToLong(DayReport::totalMinutes).sum(); + long weekCost = days.stream().mapToLong(DayReport::totalCostCents).sum(); + return new WeeklyReport(days, weekMinutes, weekCost); + } + + public MonthlyReport monthlyReport(Long userId, String yearMonth) { + String[] parts = yearMonth.split("-"); + if (parts.length != 2) { + throw new IllegalArgumentException("month must be YYYY-MM, got: " + yearMonth); + } + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); + + List projects = new ArrayList<>(); + long totalMinutes = 0; + long totalCost = 0; + + for (Object[] row : timeEntryRepository.aggregateByProjectForMonth(userId, year, month)) { + long projectId = ((Number) row[0]).longValue(); + String name = (String) row[1]; + int timeBudgetMinutes = ((Number) row[2]).intValue(); + int costBudgetCents = ((Number) row[3]).intValue(); + long minutes = ((Number) row[4]).longValue(); + long costCents = ((Number) row[5]).longValue(); + + double timePct = timeBudgetMinutes > 0 ? (minutes * 100.0 / timeBudgetMinutes) : 0; + double costPct = costBudgetCents > 0 ? (costCents * 100.0 / costBudgetCents) : 0; + boolean breached = minutes > timeBudgetMinutes || costCents > costBudgetCents; + + projects.add( + new ProjectMonthReport( + projectId, + name, + minutes, + costCents, + timeBudgetMinutes, + costBudgetCents, + timePct, + costPct, + breached)); + totalMinutes += minutes; + totalCost += costCents; + } + + return new MonthlyReport(projects, totalMinutes, totalCost); + } + + static LocalDate parseIsoWeekToMonday(String isoWeek) { + // Input format: "2026-W20" + String[] parts = isoWeek.split("-W"); + if (parts.length != 2) { + throw new IllegalArgumentException("week must be YYYY-Www, got: " + isoWeek); + } + int year = Integer.parseInt(parts[0]); + int week = Integer.parseInt(parts[1]); + // ISO week 1 is the week containing the first Thursday of the year + LocalDate jan4 = LocalDate.of(year, 1, 4); + LocalDate firstMonday = jan4.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + // adjust for ISO week number relative to the week containing Jan 4 + int weekOfJan4 = jan4.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return firstMonday.plusWeeks((long) week - weekOfJan4); + } +} diff --git a/backend/src/main/java/com/example/app/time/TimeEntry.java b/backend/src/main/java/com/example/app/time/TimeEntry.java new file mode 100644 index 0000000..81e3eeb --- /dev/null +++ b/backend/src/main/java/com/example/app/time/TimeEntry.java @@ -0,0 +1,35 @@ +package com.example.app.time; + +import com.example.app.project.Project; +import com.example.app.user.User; +import jakarta.persistence.*; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "time_entries") +@Getter +@Setter +@NoArgsConstructor +public class TimeEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Column(name = "entry_date", nullable = false) + private LocalDate entryDate; + + @Column(nullable = false) + private int durationMinutes; +} diff --git a/backend/src/main/java/com/example/app/time/TimeEntryController.java b/backend/src/main/java/com/example/app/time/TimeEntryController.java new file mode 100644 index 0000000..b0ff9a5 --- /dev/null +++ b/backend/src/main/java/com/example/app/time/TimeEntryController.java @@ -0,0 +1,48 @@ +package com.example.app.time; + +import com.example.app.time.TimeEntryDtos.CreateTimeEntryRequest; +import com.example.app.time.TimeEntryDtos.TimeEntryResponse; +import com.example.app.time.TimeEntryDtos.UpdateTimeEntryRequest; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/time-entries") +public class TimeEntryController { + + private final TimeEntryService timeEntryService; + + public TimeEntryController(TimeEntryService timeEntryService) { + this.timeEntryService = timeEntryService; + } + + @GetMapping + public List list( + @RequestParam Long userId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return timeEntryService.findByUserAndRange(userId, from, to); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public TimeEntryResponse create(@Valid @RequestBody CreateTimeEntryRequest req) { + return timeEntryService.create(req); + } + + @PutMapping("/{id}") + public TimeEntryResponse update( + @PathVariable Long id, @Valid @RequestBody UpdateTimeEntryRequest req) { + return timeEntryService.update(id, req); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + timeEntryService.delete(id); + } +} diff --git a/backend/src/main/java/com/example/app/time/TimeEntryDtos.java b/backend/src/main/java/com/example/app/time/TimeEntryDtos.java new file mode 100644 index 0000000..4180c93 --- /dev/null +++ b/backend/src/main/java/com/example/app/time/TimeEntryDtos.java @@ -0,0 +1,41 @@ +package com.example.app.time; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public class TimeEntryDtos { + + public record CreateTimeEntryRequest( + @NotNull Long userId, + @NotNull Long projectId, + @NotNull LocalDate entryDate, + @Min(1) @Max(1440) int durationMinutes) {} + + public record UpdateTimeEntryRequest( + @NotNull Long projectId, + @NotNull LocalDate entryDate, + @Min(1) @Max(1440) int durationMinutes) {} + + public record TimeEntryResponse( + Long id, + Long userId, + String userName, + Long projectId, + String projectName, + LocalDate entryDate, + int durationMinutes) { + + public static TimeEntryResponse from(TimeEntry e) { + return new TimeEntryResponse( + e.getId(), + e.getUser().getId(), + e.getUser().getName(), + e.getProject().getId(), + e.getProject().getName(), + e.getEntryDate(), + e.getDurationMinutes()); + } + } +} diff --git a/backend/src/main/java/com/example/app/time/TimeEntryRepository.java b/backend/src/main/java/com/example/app/time/TimeEntryRepository.java new file mode 100644 index 0000000..1956ac3 --- /dev/null +++ b/backend/src/main/java/com/example/app/time/TimeEntryRepository.java @@ -0,0 +1,95 @@ +package com.example.app.time; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TimeEntryRepository extends JpaRepository { + + @Query( + """ + SELECT e FROM TimeEntry e + JOIN FETCH e.user JOIN FETCH e.project + WHERE e.user.id = :userId + AND e.entryDate >= :from + AND e.entryDate <= :to + ORDER BY e.entryDate + """) + List findByUserAndDateRange( + @Param("userId") Long userId, @Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * Aggregates minutes and cost per day for a given user and date range. Returns rows of + * [entry_date, total_minutes, total_cost_cents]. + */ + @Query( + value = + """ + SELECT te.entry_date, + SUM(te.duration_minutes) AS total_minutes, + SUM(te.duration_minutes + * COALESCE(p.hourly_rate_cents, u.default_hourly_rate_cents) / 60) + AS total_cost_cents + FROM time_entries te + JOIN projects p ON p.id = te.project_id + JOIN users u ON u.id = te.user_id + WHERE te.user_id = :userId + AND te.entry_date >= :from + AND te.entry_date <= :to + GROUP BY te.entry_date + ORDER BY te.entry_date + """, + nativeQuery = true) + List aggregateByDay( + @Param("userId") Long userId, @Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * Aggregates minutes and cost per project for a given user and calendar month. Returns rows of + * [project_id, project_name, time_budget_minutes, cost_budget_cents, total_minutes, + * total_cost_cents]. + */ + @Query( + value = + """ + SELECT te.project_id, + p.name, + p.time_budget_minutes, + p.cost_budget_cents, + SUM(te.duration_minutes) AS total_minutes, + SUM(te.duration_minutes + * COALESCE(p.hourly_rate_cents, u.default_hourly_rate_cents) / 60) + AS total_cost_cents + FROM time_entries te + JOIN projects p ON p.id = te.project_id + JOIN users u ON u.id = te.user_id + WHERE te.user_id = :userId + AND EXTRACT(YEAR FROM te.entry_date) = :year + AND EXTRACT(MONTH FROM te.entry_date) = :month + GROUP BY te.project_id, p.name, p.time_budget_minutes, p.cost_budget_cents + """, + nativeQuery = true) + List aggregateByProjectForMonth( + @Param("userId") Long userId, @Param("year") int year, @Param("month") int month); + + /** + * Aggregates minutes and cost per project across all entries. Returns rows of [project_id, + * total_minutes, total_cost_cents]. + */ + @Query( + value = + """ + SELECT te.project_id, + SUM(te.duration_minutes) AS total_minutes, + SUM(te.duration_minutes + * COALESCE(p.hourly_rate_cents, u.default_hourly_rate_cents) / 60) + AS total_cost_cents + FROM time_entries te + JOIN projects p ON p.id = te.project_id + JOIN users u ON u.id = te.user_id + GROUP BY te.project_id + """, + nativeQuery = true) + List aggregateAllByProject(); +} diff --git a/backend/src/main/java/com/example/app/time/TimeEntryService.java b/backend/src/main/java/com/example/app/time/TimeEntryService.java new file mode 100644 index 0000000..486726f --- /dev/null +++ b/backend/src/main/java/com/example/app/time/TimeEntryService.java @@ -0,0 +1,86 @@ +package com.example.app.time; + +import com.example.app.project.Project; +import com.example.app.project.ProjectRepository; +import com.example.app.time.TimeEntryDtos.CreateTimeEntryRequest; +import com.example.app.time.TimeEntryDtos.TimeEntryResponse; +import com.example.app.time.TimeEntryDtos.UpdateTimeEntryRequest; +import com.example.app.user.User; +import com.example.app.user.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TimeEntryService { + + private final TimeEntryRepository timeEntryRepository; + private final UserRepository userRepository; + private final ProjectRepository projectRepository; + + public TimeEntryService( + TimeEntryRepository timeEntryRepository, + UserRepository userRepository, + ProjectRepository projectRepository) { + this.timeEntryRepository = timeEntryRepository; + this.userRepository = userRepository; + this.projectRepository = projectRepository; + } + + @Transactional(readOnly = true) + public List findByUserAndRange(Long userId, LocalDate from, LocalDate to) { + return timeEntryRepository.findByUserAndDateRange(userId, from, to).stream() + .map(TimeEntryResponse::from) + .toList(); + } + + @Transactional + public TimeEntryResponse create(CreateTimeEntryRequest req) { + User user = + userRepository + .findById(req.userId()) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + req.userId())); + Project project = + projectRepository + .findById(req.projectId()) + .orElseThrow( + () -> new EntityNotFoundException("Project not found: " + req.projectId())); + + TimeEntry entry = new TimeEntry(); + entry.setUser(user); + entry.setProject(project); + entry.setEntryDate(req.entryDate()); + entry.setDurationMinutes(req.durationMinutes()); + + return TimeEntryResponse.from(timeEntryRepository.save(entry)); + } + + @Transactional + public TimeEntryResponse update(Long id, UpdateTimeEntryRequest req) { + TimeEntry entry = + timeEntryRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("Time entry not found: " + id)); + Project project = + projectRepository + .findById(req.projectId()) + .orElseThrow( + () -> new EntityNotFoundException("Project not found: " + req.projectId())); + + entry.setProject(project); + entry.setEntryDate(req.entryDate()); + entry.setDurationMinutes(req.durationMinutes()); + + return TimeEntryResponse.from(timeEntryRepository.save(entry)); + } + + @Transactional + public void delete(Long id) { + if (!timeEntryRepository.existsById(id)) { + throw new EntityNotFoundException("Time entry not found: " + id); + } + timeEntryRepository.deleteById(id); + } +} diff --git a/backend/src/main/java/com/example/app/user/User.java b/backend/src/main/java/com/example/app/user/User.java new file mode 100644 index 0000000..f42016c --- /dev/null +++ b/backend/src/main/java/com/example/app/user/User.java @@ -0,0 +1,24 @@ +package com.example.app.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int defaultHourlyRateCents; +} diff --git a/backend/src/main/java/com/example/app/user/UserController.java b/backend/src/main/java/com/example/app/user/UserController.java new file mode 100644 index 0000000..9a94517 --- /dev/null +++ b/backend/src/main/java/com/example/app/user/UserController.java @@ -0,0 +1,23 @@ +package com.example.app.user; + +import com.example.app.user.UserDtos.UserResponse; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public List list() { + return userService.findAll(); + } +} diff --git a/backend/src/main/java/com/example/app/user/UserDtos.java b/backend/src/main/java/com/example/app/user/UserDtos.java new file mode 100644 index 0000000..0f1d2d7 --- /dev/null +++ b/backend/src/main/java/com/example/app/user/UserDtos.java @@ -0,0 +1,11 @@ +package com.example.app.user; + +public class UserDtos { + + public record UserResponse(Long id, String name, int defaultHourlyRateCents) { + + public static UserResponse from(User user) { + return new UserResponse(user.getId(), user.getName(), user.getDefaultHourlyRateCents()); + } + } +} diff --git a/backend/src/main/java/com/example/app/user/UserRepository.java b/backend/src/main/java/com/example/app/user/UserRepository.java new file mode 100644 index 0000000..f49383d --- /dev/null +++ b/backend/src/main/java/com/example/app/user/UserRepository.java @@ -0,0 +1,5 @@ +package com.example.app.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository {} diff --git a/backend/src/main/java/com/example/app/user/UserService.java b/backend/src/main/java/com/example/app/user/UserService.java new file mode 100644 index 0000000..152db4f --- /dev/null +++ b/backend/src/main/java/com/example/app/user/UserService.java @@ -0,0 +1,21 @@ +package com.example.app.user; + +import com.example.app.user.UserDtos.UserResponse; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public List findAll() { + return userRepository.findAll().stream().map(UserResponse::from).toList(); + } +}