diff --git a/server/src/main/java/com/yealch/yealch/AuthController.java b/server/src/main/java/com/yealch/yealch/AuthController.java index a36190d..3049a02 100644 --- a/server/src/main/java/com/yealch/yealch/AuthController.java +++ b/server/src/main/java/com/yealch/yealch/AuthController.java @@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; -import java.util.Map; @RestController @RequestMapping("/api") @@ -100,9 +99,13 @@ public class AuthController { return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(Map.of("status", "logged out")); + .body(new PostSignOutsResponse("logged out")); } + record ErrorResponse(String error) {} + + record PostSignOutsResponse(String status) {} + record SignUpRequest(String name, String username, String password) { } @@ -110,14 +113,14 @@ public class AuthController { public ResponseEntity createSignUp(@RequestBody SignUpRequest signUpRequest) { if (signUpRequest.username == null || signUpRequest.password == null || signUpRequest.name == null) { - return ResponseEntity.badRequest().body(Map.of("error", "Missing required fields")); + return ResponseEntity.badRequest().body(new ErrorResponse("Missing required fields")); } // TODO this allows attackers to scope out who is using the service. This needs // to be changed to a generic error message or some other solution if (userRepository.findByUsername(signUpRequest.username).isPresent()) { return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "Username already exists")); + .body(new ErrorResponse("Username already exists")); } var user = new User(); diff --git a/server/src/main/java/com/yealch/yealch/CustomUserDetails.java b/server/src/main/java/com/yealch/yealch/CustomUserDetails.java index 4759c99..bd54a0e 100644 --- a/server/src/main/java/com/yealch/yealch/CustomUserDetails.java +++ b/server/src/main/java/com/yealch/yealch/CustomUserDetails.java @@ -4,18 +4,24 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; import java.util.Collection; +import java.util.UUID; +/** + * CustomUserDetails is a class that extends the User class from Spring + * Security. It adds an additional field for the user's unique identifier + * (UUID). + */ public class CustomUserDetails extends User { - private final Long id; + private final UUID id; - public CustomUserDetails(Long id, String username, String password, + public CustomUserDetails(UUID id, String username, String password, Collection authorities) { super(username, password, authorities); this.id = id; } - public Long getId() { + public UUID getId() { return id; } -} \ No newline at end of file +} diff --git a/server/src/main/java/com/yealch/yealch/Organization.java b/server/src/main/java/com/yealch/yealch/Organization.java index de0d29f..5bd3bea 100644 --- a/server/src/main/java/com/yealch/yealch/Organization.java +++ b/server/src/main/java/com/yealch/yealch/Organization.java @@ -6,13 +6,13 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; @Entity @@ -20,8 +20,8 @@ import java.util.stream.Collectors; public class Organization { @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(nullable = false) private String name; @@ -32,7 +32,7 @@ public class Organization { @OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true) private Set projects = new HashSet<>(); - public Long getId() { + public UUID getId() { return id; } @@ -63,11 +63,11 @@ public class Organization { user.getMemberships().removeIf(membership -> this.getId().equals(membership.getOrganization().getId())); } - public boolean hasMember(Long userId) { + public boolean hasMember(UUID userId) { return memberships.stream().anyMatch(membership -> userId.equals(membership.getUser().getId())); } - public boolean hasMemberWithRole(Long userId, OrganizationRole... roles) { + public boolean hasMemberWithRole(UUID userId, OrganizationRole... roles) { Set allowedRoles = new HashSet<>(Arrays.asList(roles)); return memberships.stream().anyMatch(membership -> userId.equals(membership.getUser().getId()) && allowedRoles.contains(membership.getRole())); diff --git a/server/src/main/java/com/yealch/yealch/OrganizationMembership.java b/server/src/main/java/com/yealch/yealch/OrganizationMembership.java index 964ba47..29bd3a6 100644 --- a/server/src/main/java/com/yealch/yealch/OrganizationMembership.java +++ b/server/src/main/java/com/yealch/yealch/OrganizationMembership.java @@ -12,13 +12,15 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.util.UUID; + @Entity @Table(name = "organization_members", uniqueConstraints = @UniqueConstraint(columnNames = { "organization_id", "user_id" })) public class OrganizationMembership { @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @ManyToOne @JoinColumn(name = "organization_id", nullable = false) @@ -41,7 +43,7 @@ public class OrganizationMembership { this.role = role; } - public Long getId() { + public UUID getId() { return id; } diff --git a/server/src/main/java/com/yealch/yealch/OrganizationRepository.java b/server/src/main/java/com/yealch/yealch/OrganizationRepository.java index 5f3210b..bbd7f59 100644 --- a/server/src/main/java/com/yealch/yealch/OrganizationRepository.java +++ b/server/src/main/java/com/yealch/yealch/OrganizationRepository.java @@ -2,5 +2,7 @@ package com.yealch.yealch; import org.springframework.data.repository.CrudRepository; -public interface OrganizationRepository extends CrudRepository { -} \ No newline at end of file +import java.util.UUID; + +public interface OrganizationRepository extends CrudRepository { +} diff --git a/server/src/main/java/com/yealch/yealch/OrganizationsController.java b/server/src/main/java/com/yealch/yealch/OrganizationsController.java index 630c28f..8923e4d 100644 --- a/server/src/main/java/com/yealch/yealch/OrganizationsController.java +++ b/server/src/main/java/com/yealch/yealch/OrganizationsController.java @@ -11,8 +11,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; +import java.util.List; import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/api/organizations") @@ -26,31 +27,37 @@ public class OrganizationsController { this.userRepository = userRepository; } - record CreateOrganizationRequest(String name) { - } + record ErrorResponse(String error) {} + + record PostOrganizationsRequest(String name) {} + + record GetOrganizationsMemberResponse(UUID id, String name, String username) {} + record GetOrganizationsResponse(UUID id, String name, List users) {} + + record GetOrganizationsUsersResponse(UUID id, String name, String username) {} @PostMapping - public ResponseEntity createOrganization(@RequestBody CreateOrganizationRequest request, + public ResponseEntity createOrganization(@RequestBody PostOrganizationsRequest request, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } if (request == null || request.name() == null || request.name().isBlank()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "name is required")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("name is required")); } Organization organization = new Organization(); organization.setName(request.name()); organizationRepository.save(organization); - return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(organization)); + return ResponseEntity.status(HttpStatus.CREATED).body(toGetOrganizationsResponse(organization)); } @GetMapping("/{organizationId}") - public ResponseEntity getOrganization(@PathVariable Long organizationId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + public ResponseEntity getOrganization(@PathVariable UUID organizationId, Authentication authentication) { + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -60,20 +67,20 @@ public class OrganizationsController { if (!organization.hasMember(authenticatedUserId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - return ResponseEntity.ok(toResponse(organization)); + return ResponseEntity.ok(toGetOrganizationsResponse(organization)); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "organization not found"))); + .body(new ErrorResponse("organization not found"))); } /** * Creates a registration of a user as a member of an organization. - * Requires the authenticated user to be an owner of the organization. + * Requires the authenticated user to be an administrator or owner of the organization. */ @PostMapping("/{organizationId}/members/registrations") - public ResponseEntity addMemberToOrganization(@PathVariable Long organizationId, @PathVariable Long userId, + public ResponseEntity addMemberToOrganization(@PathVariable UUID organizationId, @PathVariable UUID userId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -82,24 +89,27 @@ public class OrganizationsController { Optional foundUser = userRepository.findById(userId); if (foundOrganization.isEmpty() || foundUser.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization or user not found")); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("organization or user not found")); } Organization organization = foundOrganization.get(); - if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.OWNER)) { + if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.ADMINISTRATOR, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } organization.addMember(foundUser.get(), OrganizationRole.MEMBER); organizationRepository.save(organization); - return ResponseEntity.ok(toResponse(organization)); + return ResponseEntity.ok(toGetOrganizationsResponse(organization)); } + /** + * Requires the authenticated user to be an administrator or owner of the organization. + */ @DeleteMapping("/{organizationId}/members/{userId}") - public ResponseEntity removeMemberFromOrganization(@PathVariable Long organizationId, - @PathVariable Long userId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + public ResponseEntity removeMemberFromOrganization(@PathVariable UUID organizationId, + @PathVariable UUID userId, Authentication authentication) { + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -108,30 +118,30 @@ public class OrganizationsController { Optional foundUser = userRepository.findById(userId); if (foundOrganization.isEmpty() || foundUser.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization or user not found")); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("organization or user not found")); } Organization organization = foundOrganization.get(); - if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.OWNER)) { + if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.ADMINISTRATOR, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } organization.removeMember(foundUser.get()); organizationRepository.save(organization); - return ResponseEntity.ok(toResponse(organization)); + return ResponseEntity.ok(toGetOrganizationsResponse(organization)); } @DeleteMapping("/{organizationId}") - public ResponseEntity deleteOrganization(@PathVariable Long organizationId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + public ResponseEntity deleteOrganization(@PathVariable UUID organizationId, Authentication authentication) { + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } Optional foundOrganization = organizationRepository.findById(organizationId); if (foundOrganization.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization not found")); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("organization not found")); } Organization organization = foundOrganization.get(); @@ -144,8 +154,8 @@ public class OrganizationsController { } @GetMapping("/{organizationId}/users") - public ResponseEntity getOrganizationUsers(@PathVariable Long organizationId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + public ResponseEntity getOrganizationUsers(@PathVariable UUID organizationId, Authentication authentication) { + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -155,23 +165,26 @@ public class OrganizationsController { if (!organization.hasMember(authenticatedUserId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - return ResponseEntity.ok(organization.getMembers().stream().map(this::toUserResponse).toList()); + return ResponseEntity.ok(organization.getMembers().stream() + .map(OrganizationsController::toGetOrganizationsUsersResponse) + .toList()); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "organization not found"))); + .body(new ErrorResponse("organization not found"))); + } + + private static GetOrganizationsMemberResponse toGetOrganizationsMemberResponse(User user) { + return new GetOrganizationsMemberResponse(user.getId(), user.getName(), user.getUsername()); } - private Map toUserResponse(User user) { - return Map.of( - "id", user.getId(), - "name", user.getName(), - "username", user.getUsername()); + private static GetOrganizationsResponse toGetOrganizationsResponse(Organization organization) { + return new GetOrganizationsResponse( + organization.getId(), + organization.getName(), + organization.getMembers().stream().map(OrganizationsController::toGetOrganizationsMemberResponse).toList()); } - private Map toResponse(Organization organization) { - return Map.of( - "id", organization.getId(), - "name", organization.getName(), - "users", organization.getMembers().stream().map(this::toUserResponse).toList()); + private static GetOrganizationsUsersResponse toGetOrganizationsUsersResponse(User user) { + return new GetOrganizationsUsersResponse(user.getId(), user.getName(), user.getUsername()); } } diff --git a/server/src/main/java/com/yealch/yealch/Project.java b/server/src/main/java/com/yealch/yealch/Project.java index 8e8ee68..3de4bd4 100644 --- a/server/src/main/java/com/yealch/yealch/Project.java +++ b/server/src/main/java/com/yealch/yealch/Project.java @@ -9,15 +9,18 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Entity @Table(name = "projects") public class Project { + @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(nullable = false) private String name; @@ -30,17 +33,9 @@ public class Project { private Organization organization; @Column(name = "organization_id", insertable = false, updatable = false) - private Long organizationId; - - public void setName(String name) { - this.name = name; - } - - public void setOrganization(Organization organization) { - this.organization = organization; - } + private UUID organizationId; - public long getId() { + public UUID getId() { return id; } @@ -48,19 +43,23 @@ public class Project { return name; } + public void setName(String name) { + this.name = name; + } + public Organization getOrganization() { return organization; } - public Long getOrganizationId() { - return organizationId; + public void setOrganization(Organization organization) { + this.organization = organization; } - public Organization getOrganizationEntity() { - return organization; + public UUID getOrganizationId() { + return organizationId; } - public void setOrganizationId(Long organizationId) { + public void setOrganizationId(UUID organizationId) { this.organizationId = organizationId; } } diff --git a/server/src/main/java/com/yealch/yealch/ProjectRepository.java b/server/src/main/java/com/yealch/yealch/ProjectRepository.java index 527c5e8..7b21f04 100644 --- a/server/src/main/java/com/yealch/yealch/ProjectRepository.java +++ b/server/src/main/java/com/yealch/yealch/ProjectRepository.java @@ -2,6 +2,7 @@ package com.yealch.yealch; import org.springframework.data.repository.CrudRepository; -public interface ProjectRepository extends CrudRepository { +import java.util.UUID; +public interface ProjectRepository extends CrudRepository { } diff --git a/server/src/main/java/com/yealch/yealch/ProjectsController.java b/server/src/main/java/com/yealch/yealch/ProjectsController.java index be314c1..deaa5f8 100644 --- a/server/src/main/java/com/yealch/yealch/ProjectsController.java +++ b/server/src/main/java/com/yealch/yealch/ProjectsController.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/projects") @@ -20,16 +20,18 @@ public class ProjectsController { this.projectRepository = projectRepository; } + record ErrorResponse(String error) {} + @DeleteMapping("/{projectId}") - public ResponseEntity deleteProject(@PathVariable Long projectId, Authentication authentication) { - Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + public ResponseEntity deleteProject(@PathVariable UUID projectId, Authentication authentication) { + UUID authenticatedUserId = UsersController.getUserId(authentication).orElse(null); if (authenticatedUserId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } var foundProject = projectRepository.findById(projectId); if (foundProject.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "project not found")); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("project not found")); } Project project = foundProject.get(); diff --git a/server/src/main/java/com/yealch/yealch/Time.java b/server/src/main/java/com/yealch/yealch/Time.java index 006c658..51f62c2 100644 --- a/server/src/main/java/com/yealch/yealch/Time.java +++ b/server/src/main/java/com/yealch/yealch/Time.java @@ -8,14 +8,17 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import java.time.OffsetDateTime; +import java.util.UUID; @Entity @Table(name = "times") public class Time { + @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(name = "start_time", nullable = false) private OffsetDateTime start; @@ -28,9 +31,9 @@ public class Time { private Project project; @Column(name = "project_id", insertable = false, updatable = false) - private Long projectId; + private UUID projectId; - public Long getId() { + public UUID getId() { return id; } @@ -59,7 +62,7 @@ public class Time { this.projectId = project == null ? null : project.getId(); } - public Long getProjectId() { + public UUID getProjectId() { return projectId; } } diff --git a/server/src/main/java/com/yealch/yealch/TimeRepository.java b/server/src/main/java/com/yealch/yealch/TimeRepository.java index c15e213..2e4668b 100644 --- a/server/src/main/java/com/yealch/yealch/TimeRepository.java +++ b/server/src/main/java/com/yealch/yealch/TimeRepository.java @@ -2,6 +2,7 @@ package com.yealch.yealch; import org.springframework.data.repository.CrudRepository; -public interface TimeRepository extends CrudRepository { +import java.util.UUID; -} \ No newline at end of file +public interface TimeRepository extends CrudRepository { +} diff --git a/server/src/main/java/com/yealch/yealch/User.java b/server/src/main/java/com/yealch/yealch/User.java index 9335198..d68114f 100644 --- a/server/src/main/java/com/yealch/yealch/User.java +++ b/server/src/main/java/com/yealch/yealch/User.java @@ -10,6 +10,7 @@ import jakarta.persistence.Table; import java.util.HashSet; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; @Entity @@ -17,8 +18,8 @@ import java.util.stream.Collectors; public class User { @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(nullable = false) private String name; @@ -32,7 +33,7 @@ public class User { @OneToMany(mappedBy = "user") private Set memberships = new HashSet<>(); - public Long getId() { + public UUID getId() { return id; } diff --git a/server/src/main/java/com/yealch/yealch/UserRepository.java b/server/src/main/java/com/yealch/yealch/UserRepository.java index d44329c..480348e 100644 --- a/server/src/main/java/com/yealch/yealch/UserRepository.java +++ b/server/src/main/java/com/yealch/yealch/UserRepository.java @@ -3,9 +3,8 @@ package com.yealch.yealch; import org.springframework.data.repository.CrudRepository; import java.util.Optional; +import java.util.UUID; -public interface UserRepository extends CrudRepository { - Optional findById(Long id); - +public interface UserRepository extends CrudRepository { Optional findByUsername(String username); } diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java index c515e64..8d9e483 100644 --- a/server/src/main/java/com/yealch/yealch/UsersController.java +++ b/server/src/main/java/com/yealch/yealch/UsersController.java @@ -13,8 +13,9 @@ import org.springframework.web.bind.annotation.RequestBody; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; -import java.util.Map; +import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.stream.StreamSupport; @RestController @@ -33,7 +34,7 @@ public class UsersController { this.timeRepository = timeRepository; } - static Optional getUserId(Authentication authentication) { + static Optional getUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { return Optional.empty(); } @@ -45,16 +46,36 @@ public class UsersController { return Optional.empty(); } + record ErrorResponse(String error) {} + + record GetUserOrganizationsResponse(UUID id, String name) {} + + record GetUserProjectsOrganizationResponse(UUID id, String name) {} + record GetUserProjectsResponse(UUID id, String name, GetUserProjectsOrganizationResponse organization) {} + + record PostUserOrganizationProjectsRequest(String name) {} + + record PostUserProjectsTimesRequest(String start, String end) {} + record PostUserTimesOrganizationResponse(UUID id, String name) {} + record PostUserTimesProjectResponse(UUID id, String name, PostUserTimesOrganizationResponse organization) {} + record PostUserTimesResponse(UUID id, String start, String end, PostUserTimesProjectResponse project) {} + + record GetUserTimesOrganizationResponse(UUID id, String name) {} + record GetUserTimesProjectResponse(UUID id, String name, GetUserTimesOrganizationResponse organization) {} + record GetUserTimesResponse(UUID id, String start, String end, GetUserTimesProjectResponse project) {} + + record UpdateProjectRequest(String name, UUID organizationId) {} + + record UpdateTimeRequest(UUID id, String start, String end) {} + /** * Gets the current user id if authenticated. Used to inspect indirectly whether - * a - * cookie is set that can be used to - * authenticate as clients don't have access to read HTTP-only cookies. + * a cookie is set that can be used to authenticate, as clients don't have + * access to read HTTP-only cookies. */ @GetMapping("/api/users/current/id") public ResponseEntity getCurrentUserId(Authentication authentication) { - - Optional userId = getUserId(authentication); + Optional userId = getUserId(authentication); if (userId.isEmpty()) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @@ -62,15 +83,9 @@ public class UsersController { return ResponseEntity.ok(userId.get().toString()); } - record CreateUserRequest(String name, String username, String password) { - } - - record GetUserOrganizationsResponse(Long id, String name) { - } - @GetMapping("/api/users/{userId}/organizations") - public ResponseEntity getUserOrganizations(@PathVariable Long userId, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + public ResponseEntity getUserOrganizations(@PathVariable UUID userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -98,7 +113,7 @@ public class UsersController { .map(organization -> new GetUserOrganizationsResponse(organization.getId(), organization.getName())) .toList()); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } /** @@ -109,8 +124,8 @@ public class UsersController { * project is created for them and assigned to the organization. */ @GetMapping("/api/users/{userId}/projects") - public ResponseEntity getUserProjects(@PathVariable Long userId, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + public ResponseEntity getUserProjects(@PathVariable UUID userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -135,30 +150,28 @@ public class UsersController { return ResponseEntity.ok(user.getOrganizations().stream() .flatMap(organization -> organization.getProjects().stream() - .map(project -> Map.of( - "id", project.getId(), - "name", project.getName(), - "organization", - Map.of("id", organization.getId(), "name", organization.getName())))) + .map(project -> new GetUserProjectsResponse( + project.getId(), + project.getName(), + new GetUserProjectsOrganizationResponse(organization.getId(), organization.getName())))) .toList()); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } /** * Creates a new project for a user within a specific organization. */ @PostMapping("/api/users/{userId}/organizations/{organizationId}/projects") - public ResponseEntity createProjectForUser(@PathVariable Long userId, @PathVariable Long organizationId, - @RequestBody Map request, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + public ResponseEntity createProjectForUser(@PathVariable UUID userId, @PathVariable UUID organizationId, + @RequestBody PostUserOrganizationProjectsRequest request, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - String projectName = request.get("name"); - if (projectName == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "project name is required")); + if (request == null || request.name() == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("project name is required")); } return userRepository.findById(userId) @@ -166,50 +179,47 @@ public class UsersController { Organization organization = organizationRepository.findById(organizationId).orElse(null); if (organization == null || !organization.hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "organization not found or user is not a member")); + .body(new ErrorResponse("organization not found or user is not a member")); } Project project = new Project(); - project.setName(projectName); + project.setName(request.name()); organization.addProject(project); organizationRepository.save(organization); return ResponseEntity.status(HttpStatus.CREATED).build(); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } /** Creates a new time entry for a user on a project */ @PostMapping("/api/users/{userId}/projects/{projectId}/times") - public ResponseEntity createTimeEntry(@PathVariable Long userId, @PathVariable Long projectId, - @RequestBody Map request, Authentication authentication) { + public ResponseEntity createTimeEntry(@PathVariable UUID userId, @PathVariable UUID projectId, + @RequestBody PostUserProjectsTimesRequest request, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - if (request == null || request.get("start") == null || request.get("end") == null) { + if (request == null || request.start() == null || request.end() == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "start and end are required")); + .body(new ErrorResponse("start and end are required")); } - String startString = request.get("start"); - String endString = request.get("end"); OffsetDateTime start; OffsetDateTime end; try { - start = OffsetDateTime.parse(startString); - end = OffsetDateTime.parse(endString); + start = OffsetDateTime.parse(request.start()); + end = OffsetDateTime.parse(request.end()); } catch (DateTimeParseException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", - "invalid start or end format, expected ISO 8601 format with timezone offset")); + .body(new ErrorResponse("invalid start or end format, expected ISO 8601 format with timezone offset")); } if (!start.isBefore(end)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "start must be before end")); + .body(new ErrorResponse("start must be before end")); } return userRepository.findById(userId) @@ -218,7 +228,7 @@ public class UsersController { Organization organization = project.getOrganization(); if (organization == null || !organization.hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "project not found or user is not a member")); + .body(new ErrorResponse("project not found or user is not a member")); } Time time = new Time(); @@ -227,26 +237,25 @@ public class UsersController { time.setProject(project); timeRepository.save(time); - return ResponseEntity.status(HttpStatus.CREATED).body(Map.of( - "id", time.getId(), - "start", time.getStart().toString(), - "end", time.getEnd().toString(), - "project", Map.of( - "id", project.getId(), - "name", project.getName(), - "organization", Map.of( - "id", organization.getId(), - "name", organization.getName())))); + return ResponseEntity.status(HttpStatus.CREATED).body(new PostUserTimesResponse( + time.getId(), + time.getStart().toString(), + time.getEnd().toString(), + new PostUserTimesProjectResponse( + project.getId(), + project.getName(), + new PostUserTimesOrganizationResponse( + organization.getId(), + organization.getName())))); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "project not found")))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); - + .body(new ErrorResponse("project not found")))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } @GetMapping("/api/users/{userId}/times") - public ResponseEntity getUserTimeEntries(@PathVariable Long userId, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + public ResponseEntity getUserTimeEntries(@PathVariable UUID userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -265,32 +274,32 @@ public class UsersController { } return organization.hasMember(user.getId()); }) - .map(time -> Map.of( - "id", time.getId(), - "start", time.getStart().toString(), - "end", time.getEnd().toString(), - "project", Map.of( - "id", time.getProject().getId(), - "name", time.getProject().getName(), - "organization", Map.of( - "id", time.getProject().getOrganization().getId(), - "name", time.getProject().getOrganization().getName())))) + .map(time -> new GetUserTimesResponse( + time.getId(), + time.getStart().toString(), + time.getEnd().toString(), + new GetUserTimesProjectResponse( + time.getProject().getId(), + time.getProject().getName(), + new GetUserTimesOrganizationResponse( + time.getProject().getOrganization().getId(), + time.getProject().getOrganization().getName())))) .toList())) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "user not found"))); + .body(new ErrorResponse("user not found"))); } /** Creates a new organization with this user in it */ @PostMapping("/api/users/{userId}/organizations") - public ResponseEntity createOrganizationForUser(@PathVariable Long userId, + public ResponseEntity createOrganizationForUser(@PathVariable UUID userId, @RequestBody String organizationName, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (organizationName == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("organization name is required")); } return userRepository.findById(userId) @@ -302,20 +311,20 @@ public class UsersController { return ResponseEntity.status(HttpStatus.CREATED).build(); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } @PutMapping("/api/users/{userId}/organizations/{organizationId}/name") - public ResponseEntity updateOrganizationName(@PathVariable Long userId, @PathVariable Long organizationId, + public ResponseEntity updateOrganizationName(@PathVariable UUID userId, @PathVariable UUID organizationId, @RequestBody String newName, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (newName == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("organization name is required")); } return userRepository.findById(userId) @@ -323,7 +332,7 @@ public class UsersController { .>map(organization -> { if (!organization.hasMemberWithRole(user.getId(), OrganizationRole.ADMINISTRATOR, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "user does not have permission to edit the organization")); + .body(new ErrorResponse("user does not have permission to edit the organization")); } organization.setName(newName); @@ -332,25 +341,22 @@ public class UsersController { return ResponseEntity.ok().build(); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "organization not found")))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); - } - - record UpdateProjectRequest(String name, Long organizationId) { + .body(new ErrorResponse("organization not found")))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } @PutMapping("/api/users/{userId}/projects/{projectId}") - public ResponseEntity updateProject(@PathVariable Long userId, @PathVariable Long projectId, + public ResponseEntity updateProject(@PathVariable UUID userId, @PathVariable UUID projectId, @RequestBody UpdateProjectRequest request, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (request == null || request.name() == null || request.organizationId() == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "name and organizationId are required")); + .body(new ErrorResponse("name and organizationId are required")); } return userRepository.findById(userId) @@ -358,7 +364,7 @@ public class UsersController { .>map(project -> { if (!project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "user is not a member of the project")); + .body(new ErrorResponse("user is not a member of the project")); } project.setName(request.name()); @@ -368,24 +374,21 @@ public class UsersController { return ResponseEntity.ok().build(); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "project not found")))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); - } - - record UpdateTimeRequest(String id, String start, String end) { + .body(new ErrorResponse("project not found")))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } @PutMapping("/api/users/{userId}/times") - public ResponseEntity updateUserTimes(@PathVariable Long userId, - @RequestBody java.util.List request, Authentication authentication) { + public ResponseEntity updateUserTimes(@PathVariable UUID userId, + @RequestBody List request, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (request == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "request body is required")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("request body is required")); } return userRepository.findById(userId) @@ -393,15 +396,7 @@ public class UsersController { for (UpdateTimeRequest item : request) { if (item == null || item.id() == null || item.start() == null || item.end() == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "id, start and end are required for each time")); - } - - Long timeId; - try { - timeId = Long.parseLong(item.id()); - } catch (NumberFormatException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "invalid time id: " + item.id())); + .body(new ErrorResponse("id, start and end are required for each time")); } OffsetDateTime start; @@ -409,21 +404,20 @@ public class UsersController { try { start = OffsetDateTime.parse(item.start()); end = OffsetDateTime.parse(item.end()); - } catch (java.time.format.DateTimeParseException e) { + } catch (DateTimeParseException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", - "invalid start or end format, expected ISO 8601 format with timezone offset")); + .body(new ErrorResponse("invalid start or end format, expected ISO 8601 format with timezone offset")); } if (!start.isBefore(end)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "start must be before end")); + .body(new ErrorResponse("start must be before end")); } - var foundTime = timeRepository.findById(timeId); + var foundTime = timeRepository.findById(item.id()); if (foundTime.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "time not found: " + timeId)); + .body(new ErrorResponse("time not found: " + item.id())); } Time time = foundTime.get(); @@ -431,7 +425,7 @@ public class UsersController { if (project == null || project.getOrganization() == null || !project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "user is not allowed to modify time: " + timeId)); + .body(new ErrorResponse("user is not allowed to modify time: " + item.id())); } time.setStart(start); @@ -441,42 +435,34 @@ public class UsersController { return ResponseEntity.ok().build(); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } @DeleteMapping("/api/users/{userId}/times") - public ResponseEntity deleteUserTimes(@PathVariable Long userId, - @RequestBody java.util.List request, Authentication authentication) { + public ResponseEntity deleteUserTimes(@PathVariable UUID userId, + @RequestBody List request, Authentication authentication) { - Optional authenticatedUserId = getUserId(authentication); + Optional authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } if (request == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "request body is required")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse("request body is required")); } return userRepository.findById(userId) .>map(user -> { - for (String rawTimeId : request) { - if (rawTimeId == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "time id must be provided")); - } - - Long timeId; - try { - timeId = Long.parseLong(rawTimeId); - } catch (NumberFormatException e) { + for (UUID timeId : request) { + if (timeId == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "invalid time id: " + rawTimeId)); + .body(new ErrorResponse("time id must be provided")); } var foundTime = timeRepository.findById(timeId); if (foundTime.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "time not found: " + timeId)); + .body(new ErrorResponse("time not found: " + timeId)); } Time time = foundTime.get(); @@ -484,7 +470,7 @@ public class UsersController { if (project == null || project.getOrganization() == null || !project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "user is not allowed to delete time: " + timeId)); + .body(new ErrorResponse("user is not allowed to delete time: " + timeId)); } timeRepository.delete(time); @@ -492,6 +478,6 @@ public class UsersController { return ResponseEntity.ok().build(); }) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); } }