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,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();
}
}