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:
2026-05-16 11:41:23 +02:00
parent ffa357775b
commit ad23616c7b
20 changed files with 764 additions and 0 deletions
@@ -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);
}
}