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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user