From 2dedd5508f1ddc6ae4e9ceab704fb23055123141 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 8 Mar 2026 10:50:03 -0600 Subject: [PATCH] Add customer registration, profile management and refunds --- .gitignore | 4 + .../backend/config/DataInitializer.java | 10 + .../backend/controller/AuthController.java | 199 ++++++++++++++++++ .../backend/controller/RefundController.java | 113 ++++++++++ .../dto/auth/AvatarUploadResponse.java | 54 +++++ .../dto/auth/ProfileUpdateRequest.java | 77 +++++++ .../backend/dto/auth/RegisterRequest.java | 82 ++++++++ .../backend/dto/auth/RegisterResponse.java | 90 ++++++++ .../backend/dto/auth/UserInfoResponse.java | 44 +++- .../backend/dto/refund/RefundRequest.java | 51 +++++ .../backend/dto/refund/RefundResponse.java | 128 +++++++++++ .../dto/refund/RefundUpdateRequest.java | 37 ++++ .../com/petshop/backend/entity/Refund.java | 151 +++++++++++++ .../java/com/petshop/backend/entity/Sale.java | 16 +- .../java/com/petshop/backend/entity/User.java | 43 +++- .../backend/repository/RefundRepository.java | 13 ++ .../backend/repository/UserRepository.java | 1 + .../backend/security/SecurityConfig.java | 12 +- .../backend/service/RefundService.java | 111 ++++++++++ src/main/resources/application.yml | 6 + src/main/resources/schema.sql | 40 ++++ 21 files changed, 1268 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/petshop/backend/controller/RefundController.java create mode 100644 src/main/java/com/petshop/backend/dto/auth/AvatarUploadResponse.java create mode 100644 src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java create mode 100644 src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java create mode 100644 src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java create mode 100644 src/main/java/com/petshop/backend/dto/refund/RefundRequest.java create mode 100644 src/main/java/com/petshop/backend/dto/refund/RefundResponse.java create mode 100644 src/main/java/com/petshop/backend/dto/refund/RefundUpdateRequest.java create mode 100644 src/main/java/com/petshop/backend/entity/Refund.java create mode 100644 src/main/java/com/petshop/backend/repository/RefundRepository.java create mode 100644 src/main/java/com/petshop/backend/service/RefundService.java diff --git a/.gitignore b/.gitignore index 34394cf7..3ef9c153 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ build/ ### Mac ### .DS_Store + +### Project Specific ### +tmp/ +uploads/ diff --git a/src/main/java/com/petshop/backend/config/DataInitializer.java b/src/main/java/com/petshop/backend/config/DataInitializer.java index a3db73ab..367ecc38 100644 --- a/src/main/java/com/petshop/backend/config/DataInitializer.java +++ b/src/main/java/com/petshop/backend/config/DataInitializer.java @@ -34,5 +34,15 @@ public class DataInitializer implements CommandLineRunner { staff.setRole(User.Role.STAFF); userRepository.save(staff); } + + if (userRepository.findByUsername("customer").isEmpty()) { + User customer = new User(); + customer.setUsername("customer"); + customer.setPassword(passwordEncoder.encode("customer123")); + customer.setEmail("customer@petshop.com"); + customer.setFullName("Test Customer"); + customer.setRole(User.Role.CUSTOMER); + userRepository.save(customer); + } } } diff --git a/src/main/java/com/petshop/backend/controller/AuthController.java b/src/main/java/com/petshop/backend/controller/AuthController.java index 4055893e..ae28d126 100644 --- a/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/src/main/java/com/petshop/backend/controller/AuthController.java @@ -1,7 +1,11 @@ package com.petshop.backend.controller; +import com.petshop.backend.dto.auth.AvatarUploadResponse; import com.petshop.backend.dto.auth.LoginRequest; import com.petshop.backend.dto.auth.LoginResponse; +import com.petshop.backend.dto.auth.ProfileUpdateRequest; +import com.petshop.backend.dto.auth.RegisterRequest; +import com.petshop.backend.dto.auth.RegisterResponse; import com.petshop.backend.dto.auth.UserInfoResponse; import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; @@ -18,9 +22,17 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/v1/auth") @@ -38,6 +50,46 @@ public class AuthController { this.passwordEncoder = passwordEncoder; } + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Username already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + + if (userRepository.findByEmail(request.getEmail()).isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Email already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setEmail(request.getEmail()); + user.setFullName(request.getFullName()); + user.setRole(User.Role.CUSTOMER); + + User savedUser = userRepository.save(user); + + UserDetails userDetails = new org.springframework.security.core.userdetails.User( + savedUser.getUsername(), + savedUser.getPassword(), + java.util.Collections.emptyList() + ); + + String token = jwtUtil.generateToken(userDetails); + + return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse( + savedUser.getId(), + savedUser.getUsername(), + savedUser.getEmail(), + savedUser.getRole().name(), + token + )); + } + @PostMapping("/login") public ResponseEntity login(@Valid @RequestBody LoginRequest request) { try { @@ -80,10 +132,157 @@ public class AuthController { return ResponseEntity.ok(new UserInfoResponse( user.getId(), user.getUsername(), + user.getEmail(), + user.getFullName(), + user.getAvatarUrl(), user.getRole().name() )); } + @PutMapping("/me") + public ResponseEntity updateProfile(@Valid @RequestBody ProfileUpdateRequest request) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (request.getUsername() != null && !request.getUsername().equals(user.getUsername())) { + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Username already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + user.setUsername(request.getUsername()); + } + + if (request.getEmail() != null && !request.getEmail().equals(user.getEmail())) { + if (userRepository.findByEmail(request.getEmail()).isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Email already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + user.setEmail(request.getEmail()); + } + + if (request.getFullName() != null) { + user.setFullName(request.getFullName()); + } + + if (request.getPassword() != null && !request.getPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(request.getPassword())); + } + + User updatedUser = userRepository.save(user); + + return ResponseEntity.ok(new UserInfoResponse( + updatedUser.getId(), + updatedUser.getUsername(), + updatedUser.getEmail(), + updatedUser.getFullName(), + updatedUser.getAvatarUrl(), + updatedUser.getRole().name() + )); + } + + @PostMapping("/me/avatar") + public ResponseEntity uploadAvatar(@RequestParam("avatar") MultipartFile file) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (file.isEmpty()) { + Map error = new HashMap<>(); + error.put("message", "Please select a file to upload"); + return ResponseEntity.badRequest().body(error); + } + + if (file.getSize() > 5 * 1024 * 1024) { + Map error = new HashMap<>(); + error.put("message", "File size must not exceed 5MB"); + return ResponseEntity.badRequest().body(error); + } + + String contentType = file.getContentType(); + if (contentType == null || (!contentType.equals("image/jpeg") && !contentType.equals("image/png") && !contentType.equals("image/gif"))) { + Map error = new HashMap<>(); + error.put("message", "Only JPG, PNG, and GIF images are allowed"); + return ResponseEntity.badRequest().body(error); + } + + try { + String uploadDir = "uploads/avatars"; + File directory = new File(uploadDir); + if (!directory.exists()) { + directory.mkdirs(); + } + + String originalFilename = file.getOriginalFilename(); + String extension = originalFilename != null && originalFilename.contains(".") + ? originalFilename.substring(originalFilename.lastIndexOf(".")) + : ".jpg"; + String filename = UUID.randomUUID().toString() + extension; + Path filePath = Paths.get(uploadDir, filename); + + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + + String avatarUrl = "/uploads/avatars/" + filename; + user.setAvatarUrl(avatarUrl); + userRepository.save(user); + + return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully")); + + } catch (IOException e) { + Map error = new HashMap<>(); + error.put("message", "Failed to upload avatar: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + @GetMapping("/me/avatar") + public ResponseEntity getAvatar() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) { + Map error = new HashMap<>(); + error.put("message", "No avatar uploaded"); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + Map response = new HashMap<>(); + response.put("avatarUrl", user.getAvatarUrl()); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/me/avatar") + public ResponseEntity deleteAvatar() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) { + try { + Path filePath = Paths.get("." + user.getAvatarUrl()); + Files.deleteIfExists(filePath); + } catch (IOException e) { + } + user.setAvatarUrl(null); + userRepository.save(user); + } + + Map response = new HashMap<>(); + response.put("message", "Avatar deleted successfully"); + return ResponseEntity.ok(response); + } + @PostMapping("/logout") public ResponseEntity logout() { Map response = new HashMap<>(); diff --git a/src/main/java/com/petshop/backend/controller/RefundController.java b/src/main/java/com/petshop/backend/controller/RefundController.java new file mode 100644 index 00000000..57dcc889 --- /dev/null +++ b/src/main/java/com/petshop/backend/controller/RefundController.java @@ -0,0 +1,113 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.refund.RefundRequest; +import com.petshop.backend.dto.refund.RefundResponse; +import com.petshop.backend.dto.refund.RefundUpdateRequest; +import com.petshop.backend.service.RefundService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/refunds") +public class RefundController { + + private final RefundService refundService; + + public RefundController(RefundService refundService) { + this.refundService = refundService; + } + + @PostMapping + @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") + public ResponseEntity createRefund(@Valid @RequestBody RefundRequest request) { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String role = authentication.getAuthorities().stream() + .findFirst() + .map(authority -> authority.getAuthority().replace("ROLE_", "")) + .orElse(null); + + Long customerId = role != null && role.equals("CUSTOMER") ? 1L : null; + + RefundResponse refund = refundService.createRefund(request, customerId); + return ResponseEntity.status(HttpStatus.CREATED).body(refund); + } catch (RuntimeException e) { + Map error = new HashMap<>(); + error.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(error); + } + } + + @GetMapping + @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") + public ResponseEntity> getAllRefunds() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String role = authentication.getAuthorities().stream() + .findFirst() + .map(authority -> authority.getAuthority().replace("ROLE_", "")) + .orElse(null); + + Long customerId = role != null && role.equals("CUSTOMER") ? 1L : null; + + List refunds = refundService.getAllRefunds(customerId); + return ResponseEntity.ok(refunds); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") + public ResponseEntity getRefundById(@PathVariable Long id) { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String role = authentication.getAuthorities().stream() + .findFirst() + .map(authority -> authority.getAuthority().replace("ROLE_", "")) + .orElse(null); + + Long customerId = role != null && role.equals("CUSTOMER") ? 1L : null; + + RefundResponse refund = refundService.getRefundById(id, customerId); + return ResponseEntity.ok(refund); + } catch (RuntimeException e) { + Map error = new HashMap<>(); + error.put("message", e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) { + try { + RefundResponse refund = refundService.updateRefundStatus(id, request.getStatus()); + return ResponseEntity.ok(refund); + } catch (RuntimeException e) { + Map error = new HashMap<>(); + error.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(error); + } + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteRefund(@PathVariable Long id) { + try { + refundService.deleteRefund(id); + Map response = new HashMap<>(); + response.put("message", "Refund deleted successfully"); + return ResponseEntity.ok(response); + } catch (RuntimeException e) { + Map error = new HashMap<>(); + error.put("message", e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + } +} diff --git a/src/main/java/com/petshop/backend/dto/auth/AvatarUploadResponse.java b/src/main/java/com/petshop/backend/dto/auth/AvatarUploadResponse.java new file mode 100644 index 00000000..8e75af96 --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/auth/AvatarUploadResponse.java @@ -0,0 +1,54 @@ +package com.petshop.backend.dto.auth; + +import java.util.Objects; + +public class AvatarUploadResponse { + private String avatarUrl; + private String message; + + public AvatarUploadResponse() { + } + + public AvatarUploadResponse(String avatarUrl, String message) { + this.avatarUrl = avatarUrl; + this.message = message; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AvatarUploadResponse that = (AvatarUploadResponse) o; + return Objects.equals(avatarUrl, that.avatarUrl) && + Objects.equals(message, that.message); + } + + @Override + public int hashCode() { + return Objects.hash(avatarUrl, message); + } + + @Override + public String toString() { + return "AvatarUploadResponse{" + + "avatarUrl='" + avatarUrl + '\'' + + ", message='" + message + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java b/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java new file mode 100644 index 00000000..ae7d6270 --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java @@ -0,0 +1,77 @@ +package com.petshop.backend.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import java.util.Objects; + +public class ProfileUpdateRequest { + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + private String username; + + @Email(message = "Email must be valid") + private String email; + + @Size(max = 100, message = "Full name must not exceed 100 characters") + private String fullName; + + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProfileUpdateRequest that = (ProfileUpdateRequest) o; + return Objects.equals(username, that.username) && + Objects.equals(email, that.email) && + Objects.equals(fullName, that.fullName) && + Objects.equals(password, that.password); + } + + @Override + public int hashCode() { + return Objects.hash(username, email, fullName, password); + } + + @Override + public String toString() { + return "ProfileUpdateRequest{" + + "username='" + username + '\'' + + ", email='" + email + '\'' + + ", fullName='" + fullName + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java b/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java new file mode 100644 index 00000000..07775bad --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java @@ -0,0 +1,82 @@ +package com.petshop.backend.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.Objects; + +public class RegisterRequest { + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + private String email; + + @NotBlank(message = "Full name is required") + @Size(max = 100, message = "Full name must not exceed 100 characters") + private String fullName; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RegisterRequest that = (RegisterRequest) o; + return Objects.equals(username, that.username) && + Objects.equals(password, that.password) && + Objects.equals(email, that.email) && + Objects.equals(fullName, that.fullName); + } + + @Override + public int hashCode() { + return Objects.hash(username, password, email, fullName); + } + + @Override + public String toString() { + return "RegisterRequest{" + + "username='" + username + '\'' + + ", password='" + password + '\'' + + ", email='" + email + '\'' + + ", fullName='" + fullName + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java b/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java new file mode 100644 index 00000000..b31370cb --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java @@ -0,0 +1,90 @@ +package com.petshop.backend.dto.auth; + +import java.util.Objects; + +public class RegisterResponse { + private Long id; + private String username; + private String email; + private String role; + private String token; + + public RegisterResponse() { + } + + public RegisterResponse(Long id, String username, String email, String role, String token) { + this.id = id; + this.username = username; + this.email = email; + this.role = role; + this.token = token; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RegisterResponse that = (RegisterResponse) o; + return Objects.equals(id, that.id) && + Objects.equals(username, that.username) && + Objects.equals(email, that.email) && + Objects.equals(role, that.role) && + Objects.equals(token, that.token); + } + + @Override + public int hashCode() { + return Objects.hash(id, username, email, role, token); + } + + @Override + public String toString() { + return "RegisterResponse{" + + "id=" + id + + ", username='" + username + '\'' + + ", email='" + email + '\'' + + ", role='" + role + '\'' + + ", token='" + token + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java b/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java index 006727c9..7ce89f8a 100644 --- a/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java +++ b/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java @@ -5,14 +5,20 @@ import java.util.Objects; public class UserInfoResponse { private Long id; private String username; + private String email; + private String fullName; + private String avatarUrl; private String role; public UserInfoResponse() { } - public UserInfoResponse(Long id, String username, String role) { + public UserInfoResponse(Long id, String username, String email, String fullName, String avatarUrl, String role) { this.id = id; this.username = username; + this.email = email; + this.fullName = fullName; + this.avatarUrl = avatarUrl; this.role = role; } @@ -32,6 +38,30 @@ public class UserInfoResponse { this.username = username; } + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + public String getRole() { return role; } @@ -45,12 +75,17 @@ public class UserInfoResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserInfoResponse that = (UserInfoResponse) o; - return Objects.equals(id, that.id) && Objects.equals(username, that.username) && Objects.equals(role, that.role); + return Objects.equals(id, that.id) && + Objects.equals(username, that.username) && + Objects.equals(email, that.email) && + Objects.equals(fullName, that.fullName) && + Objects.equals(avatarUrl, that.avatarUrl) && + Objects.equals(role, that.role); } @Override public int hashCode() { - return Objects.hash(id, username, role); + return Objects.hash(id, username, email, fullName, avatarUrl, role); } @Override @@ -58,6 +93,9 @@ public class UserInfoResponse { return "UserInfoResponse{" + "id=" + id + ", username='" + username + '\'' + + ", email='" + email + '\'' + + ", fullName='" + fullName + '\'' + + ", avatarUrl='" + avatarUrl + '\'' + ", role='" + role + '\'' + '}'; } diff --git a/src/main/java/com/petshop/backend/dto/refund/RefundRequest.java b/src/main/java/com/petshop/backend/dto/refund/RefundRequest.java new file mode 100644 index 00000000..aa94588b --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/refund/RefundRequest.java @@ -0,0 +1,51 @@ +package com.petshop.backend.dto.refund; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; + +public class RefundRequest { + @NotNull(message = "Sale ID is required") + private Long saleId; + + @NotBlank(message = "Reason is required") + private String reason; + + public Long getSaleId() { + return saleId; + } + + public void setSaleId(Long saleId) { + this.saleId = saleId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RefundRequest that = (RefundRequest) o; + return Objects.equals(saleId, that.saleId) && + Objects.equals(reason, that.reason); + } + + @Override + public int hashCode() { + return Objects.hash(saleId, reason); + } + + @Override + public String toString() { + return "RefundRequest{" + + "saleId=" + saleId + + ", reason='" + reason + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/refund/RefundResponse.java b/src/main/java/com/petshop/backend/dto/refund/RefundResponse.java new file mode 100644 index 00000000..83a2cd1b --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/refund/RefundResponse.java @@ -0,0 +1,128 @@ +package com.petshop.backend.dto.refund; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +public class RefundResponse { + private Long id; + private Long saleId; + private Long customerId; + private BigDecimal amount; + private String reason; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public RefundResponse() { + } + + public RefundResponse(Long id, Long saleId, Long customerId, BigDecimal amount, String reason, String status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.saleId = saleId; + this.customerId = customerId; + this.amount = amount; + this.reason = reason; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getSaleId() { + return saleId; + } + + public void setSaleId(Long saleId) { + this.saleId = saleId; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RefundResponse that = (RefundResponse) o; + return Objects.equals(id, that.id) && + Objects.equals(saleId, that.saleId) && + Objects.equals(customerId, that.customerId) && + Objects.equals(amount, that.amount) && + Objects.equals(reason, that.reason) && + Objects.equals(status, that.status) && + Objects.equals(createdAt, that.createdAt) && + Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, saleId, customerId, amount, reason, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "RefundResponse{" + + "id=" + id + + ", saleId=" + saleId + + ", customerId=" + customerId + + ", amount=" + amount + + ", reason='" + reason + '\'' + + ", status='" + status + '\'' + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/dto/refund/RefundUpdateRequest.java b/src/main/java/com/petshop/backend/dto/refund/RefundUpdateRequest.java new file mode 100644 index 00000000..22dddf95 --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/refund/RefundUpdateRequest.java @@ -0,0 +1,37 @@ +package com.petshop.backend.dto.refund; + +import jakarta.validation.constraints.NotBlank; +import java.util.Objects; + +public class RefundUpdateRequest { + @NotBlank(message = "Status is required") + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RefundUpdateRequest that = (RefundUpdateRequest) o; + return Objects.equals(status, that.status); + } + + @Override + public int hashCode() { + return Objects.hash(status); + } + + @Override + public String toString() { + return "RefundUpdateRequest{" + + "status='" + status + '\'' + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/entity/Refund.java b/src/main/java/com/petshop/backend/entity/Refund.java new file mode 100644 index 00000000..8addc548 --- /dev/null +++ b/src/main/java/com/petshop/backend/entity/Refund.java @@ -0,0 +1,151 @@ +package com.petshop.backend.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Table(name = "refund") +public class Refund { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long saleId; + + @Column(nullable = false) + private Long customerId; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, length = 500) + private String reason; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private RefundStatus status; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public enum RefundStatus { + PENDING, APPROVED, REJECTED + } + + public Refund() { + } + + public Refund(Long id, Long saleId, Long customerId, BigDecimal amount, String reason, RefundStatus status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.saleId = saleId; + this.customerId = customerId; + this.amount = amount; + this.reason = reason; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getSaleId() { + return saleId; + } + + public void setSaleId(Long saleId) { + this.saleId = saleId; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public RefundStatus getStatus() { + return status; + } + + public void setStatus(RefundStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Refund refund = (Refund) o; + return Objects.equals(id, refund.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Refund{" + + "id=" + id + + ", saleId=" + saleId + + ", customerId=" + customerId + + ", amount=" + amount + + ", reason='" + reason + '\'' + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } +} diff --git a/src/main/java/com/petshop/backend/entity/Sale.java b/src/main/java/com/petshop/backend/entity/Sale.java index 7f844c39..c60c0927 100644 --- a/src/main/java/com/petshop/backend/entity/Sale.java +++ b/src/main/java/com/petshop/backend/entity/Sale.java @@ -29,6 +29,10 @@ public class Sale { @JoinColumn(name = "storeId", nullable = false) private StoreLocation store; + @ManyToOne + @JoinColumn(name = "customerId") + private Customer customer; + @Column(nullable = false, precision = 10, scale = 2) private BigDecimal totalAmount; @@ -56,11 +60,12 @@ public class Sale { public Sale() { } - public Sale(Long saleId, LocalDateTime saleDate, Employee employee, StoreLocation store, BigDecimal totalAmount, String paymentMethod, Boolean isRefund, Sale originalSale, List items, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Sale(Long saleId, LocalDateTime saleDate, Employee employee, StoreLocation store, Customer customer, BigDecimal totalAmount, String paymentMethod, Boolean isRefund, Sale originalSale, List items, LocalDateTime createdAt, LocalDateTime updatedAt) { this.saleId = saleId; this.saleDate = saleDate; this.employee = employee; this.store = store; + this.customer = customer; this.totalAmount = totalAmount; this.paymentMethod = paymentMethod; this.isRefund = isRefund; @@ -102,6 +107,14 @@ public class Sale { this.store = store; } + public Customer getCustomer() { + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + public BigDecimal getTotalAmount() { return totalAmount; } @@ -178,6 +191,7 @@ public class Sale { ", saleDate=" + saleDate + ", employee=" + employee + ", store=" + store + + ", customer=" + customer + ", totalAmount=" + totalAmount + ", paymentMethod='" + paymentMethod + '\'' + ", isRefund=" + isRefund + diff --git a/src/main/java/com/petshop/backend/entity/User.java b/src/main/java/com/petshop/backend/entity/User.java index a8c42d8f..6ef37551 100644 --- a/src/main/java/com/petshop/backend/entity/User.java +++ b/src/main/java/com/petshop/backend/entity/User.java @@ -21,6 +21,15 @@ public class User { @Column(nullable = false) private String password; + @Column(unique = true, length = 100) + private String email; + + @Column(length = 100) + private String fullName; + + @Column(length = 255) + private String avatarUrl; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20, columnDefinition = "VARCHAR(20)") private Role role; @@ -34,16 +43,19 @@ public class User { private LocalDateTime updatedAt; public enum Role { - STAFF, ADMIN + CUSTOMER, STAFF, ADMIN } public User() { } - public User(Long id, String username, String password, Role role, LocalDateTime createdAt, LocalDateTime updatedAt) { + public User(Long id, String username, String password, String email, String fullName, String avatarUrl, Role role, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.username = username; this.password = password; + this.email = email; + this.fullName = fullName; + this.avatarUrl = avatarUrl; this.role = role; this.createdAt = createdAt; this.updatedAt = updatedAt; @@ -73,6 +85,30 @@ public class User { this.password = password; } + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + public Role getRole() { return role; } @@ -116,6 +152,9 @@ public class User { "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + + ", email='" + email + '\'' + + ", fullName='" + fullName + '\'' + + ", avatarUrl='" + avatarUrl + '\'' + ", role=" + role + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + diff --git a/src/main/java/com/petshop/backend/repository/RefundRepository.java b/src/main/java/com/petshop/backend/repository/RefundRepository.java new file mode 100644 index 00000000..b71dde0c --- /dev/null +++ b/src/main/java/com/petshop/backend/repository/RefundRepository.java @@ -0,0 +1,13 @@ +package com.petshop.backend.repository; + +import com.petshop.backend.entity.Refund; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RefundRepository extends JpaRepository { + List findByCustomerId(Long customerId); + List findBySaleId(Long saleId); +} diff --git a/src/main/java/com/petshop/backend/repository/UserRepository.java b/src/main/java/com/petshop/backend/repository/UserRepository.java index 187d73c4..17e4356a 100644 --- a/src/main/java/com/petshop/backend/repository/UserRepository.java +++ b/src/main/java/com/petshop/backend/repository/UserRepository.java @@ -13,6 +13,7 @@ import java.util.Optional; @Repository public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); boolean existsByUsername(String username); @Query("SELECT u FROM User u WHERE " + diff --git a/src/main/java/com/petshop/backend/security/SecurityConfig.java b/src/main/java/com/petshop/backend/security/SecurityConfig.java index 1e96882c..f4293aa7 100644 --- a/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -36,18 +36,14 @@ public class SecurityConfig { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll() .requestMatchers("/api/v1/health").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/pets/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/sales/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/suppliers").hasRole("ADMIN") - .requestMatchers("/api/v1/inventory/**").hasRole("ADMIN") - .requestMatchers("/api/v1/suppliers/**").hasRole("ADMIN") - .requestMatchers("/api/v1/product-suppliers/**").hasRole("ADMIN") - .requestMatchers("/api/v1/purchase-orders/**").hasRole("ADMIN") - .requestMatchers("/api/v1/users/**").hasRole("ADMIN") - .requestMatchers("/api/v1/analytics/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/services/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll() .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) diff --git a/src/main/java/com/petshop/backend/service/RefundService.java b/src/main/java/com/petshop/backend/service/RefundService.java new file mode 100644 index 00000000..ec7f4205 --- /dev/null +++ b/src/main/java/com/petshop/backend/service/RefundService.java @@ -0,0 +1,111 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.refund.RefundRequest; +import com.petshop.backend.dto.refund.RefundResponse; +import com.petshop.backend.entity.Refund; +import com.petshop.backend.entity.Sale; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.RefundRepository; +import com.petshop.backend.repository.SaleRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class RefundService { + + private final RefundRepository refundRepository; + private final SaleRepository saleRepository; + + public RefundService(RefundRepository refundRepository, SaleRepository saleRepository) { + this.refundRepository = refundRepository; + this.saleRepository = saleRepository; + } + + @Transactional + public RefundResponse createRefund(RefundRequest request, Long customerId) { + Sale sale = saleRepository.findById(request.getSaleId()) + .orElseThrow(() -> new RuntimeException("Sale not found")); + + if (sale.getCustomer() == null) { + throw new RuntimeException("Sale has no associated customer"); + } + + if (customerId != null && !sale.getCustomer().getCustomerId().equals(customerId)) { + throw new RuntimeException("You can only create refunds for your own purchases"); + } + + Refund refund = new Refund(); + refund.setSaleId(sale.getSaleId()); + refund.setCustomerId(sale.getCustomer().getCustomerId()); + refund.setAmount(sale.getTotalAmount()); + refund.setReason(request.getReason()); + refund.setStatus(Refund.RefundStatus.PENDING); + + Refund savedRefund = refundRepository.save(refund); + return toResponse(savedRefund); + } + + public RefundResponse getRefundById(Long id, Long customerId) { + Refund refund = refundRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Refund not found")); + + if (customerId != null && !refund.getCustomerId().equals(customerId)) { + throw new RuntimeException("You can only view your own refunds"); + } + + return toResponse(refund); + } + + public List getAllRefunds(Long customerId) { + List refunds; + + if (customerId != null) { + refunds = refundRepository.findByCustomerId(customerId); + } else { + refunds = refundRepository.findAll(); + } + + return refunds.stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + @Transactional + public RefundResponse updateRefundStatus(Long id, String status) { + Refund refund = refundRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Refund not found")); + + try { + refund.setStatus(Refund.RefundStatus.valueOf(status.toUpperCase())); + } catch (IllegalArgumentException e) { + throw new RuntimeException("Invalid status: " + status); + } + + Refund updatedRefund = refundRepository.save(refund); + return toResponse(updatedRefund); + } + + @Transactional + public void deleteRefund(Long id) { + if (!refundRepository.existsById(id)) { + throw new RuntimeException("Refund not found"); + } + refundRepository.deleteById(id); + } + + private RefundResponse toResponse(Refund refund) { + return new RefundResponse( + refund.getId(), + refund.getSaleId(), + refund.getCustomerId(), + refund.getAmount(), + refund.getReason(), + refund.getStatus().name(), + refund.getCreatedAt(), + refund.getUpdatedAt() + ); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2b7c9b34..889c126e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,12 @@ spring: application: name: petshop-backend + servlet: + multipart: + enabled: true + max-file-size: 5MB + max-request-size: 5MB + datasource: url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC} username: ${SPRING_DATASOURCE_USERNAME:petshop} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 35ee87ca..76e63b85 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -152,12 +152,14 @@ CREATE TABLE IF NOT EXISTS sale ( paymentMethod VARCHAR(50) NOT NULL, employeeId BIGINT NOT NULL, storeId BIGINT NOT NULL, + customerId BIGINT NULL, isRefund BOOLEAN DEFAULT FALSE NOT NULL, originalSaleId BIGINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (employeeId) REFERENCES employee(employeeId), FOREIGN KEY (storeId) REFERENCES storeLocation(storeId), + FOREIGN KEY (customerId) REFERENCES customer(customerId), FOREIGN KEY (originalSaleId) REFERENCES sale(saleId) ); @@ -195,7 +197,45 @@ CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, + email VARCHAR(100) UNIQUE, + fullName VARCHAR(100), + avatarUrl VARCHAR(255), role VARCHAR(20) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); + +CREATE TABLE IF NOT EXISTS refund ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + saleId BIGINT NOT NULL, + customerId BIGINT NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + reason VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (saleId) REFERENCES sale(saleId), + FOREIGN KEY (customerId) REFERENCES customer(customerId) +); + +CREATE TABLE IF NOT EXISTS conversation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + customerId BIGINT NOT NULL, + staffId BIGINT, + status VARCHAR(20) DEFAULT 'OPEN', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (customerId) REFERENCES customer(customerId), + FOREIGN KEY (staffId) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS message ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + conversationId BIGINT NOT NULL, + senderId BIGINT NOT NULL, + content TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + isRead BOOLEAN DEFAULT FALSE, + FOREIGN KEY (conversationId) REFERENCES conversation(id), + FOREIGN KEY (senderId) REFERENCES users(id) +);