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
+5
View File
@@ -46,6 +46,11 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<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();
}
}