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:
@@ -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