feat(backend): implement domain — users, projects, time entries, reports
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,11 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<ProjectResponse> list() {
|
||||||
|
return projectService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
|
||||||
|
return projectService.create(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.app.project;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ProjectRepository extends JpaRepository<Project, Long> {}
|
||||||
@@ -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<ProjectResponse> findAll() {
|
||||||
|
List<Project> projects = projectRepository.findAll();
|
||||||
|
|
||||||
|
// one aggregate query for totals across all entries
|
||||||
|
Map<Long, long[]> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DayReport> 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<ProjectMonthReport> projects, long monthTotalMinutes, long monthTotalCostCents) {}
|
||||||
|
}
|
||||||
@@ -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<LocalDate, long[]> 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<DayReport> 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<ProjectMonthReport> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<TimeEntryResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TimeEntry, Long> {
|
||||||
|
|
||||||
|
@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<TimeEntry> 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<Object[]> 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<Object[]> 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<Object[]> aggregateAllByProject();
|
||||||
|
}
|
||||||
@@ -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<TimeEntryResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<UserResponse> list() {
|
||||||
|
return userService.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.app.user;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {}
|
||||||
@@ -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<UserResponse> findAll() {
|
||||||
|
return userRepository.findAll().stream().map(UserResponse::from).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user