diff --git a/pom.xml b/pom.xml index 873c239..e217d5d 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,11 @@ org.thymeleaf.extras thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-oauth2-client + diff --git a/src/main/java/com/bookingtour/sun/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/bookingtour/sun/config/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..05b20e6 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/config/OAuth2LoginSuccessHandler.java @@ -0,0 +1,62 @@ +package com.bookingtour.sun.config; + +import com.bookingtour.sun.entity.User; +import com.bookingtour.sun.enums.LoginProvider; +import com.bookingtour.sun.enums.UserRole; +import com.bookingtour.sun.enums.UserStatus; +import com.bookingtour.sun.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class OAuth2LoginSuccessHandler + extends SavedRequestAwareAuthenticationSuccessHandler { + + @Autowired + UserRepository userRepository; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) + throws IOException, ServletException { + + OAuth2User user = (OAuth2User) authentication.getPrincipal(); + + String email = user.getAttribute("email"); + + String name = user.getAttribute("name"); + + + User dbUser = userRepository.findByEmail(email) + .orElseGet(() -> { + User u = new User(); + u.setEmail(email); + u.setFullName(name); + String username = name != null ? name : email; + if (username != null && username.length() > 50) { + username = username.substring(0, 50); + } + u.setUsername(username); + u.setRole(UserRole.USER); + u.setStatus(UserStatus.ACTIVE); + u.setPasswordDigest(""); + u.setLoginProvider(LoginProvider.GOOGLE); + return userRepository.save(u); + }); + + super.onAuthenticationSuccess( + request, + response, + authentication); + } +} diff --git a/src/main/java/com/bookingtour/sun/config/SecurityConfig.java b/src/main/java/com/bookingtour/sun/config/SecurityConfig.java index 83f8858..b3d5510 100644 --- a/src/main/java/com/bookingtour/sun/config/SecurityConfig.java +++ b/src/main/java/com/bookingtour/sun/config/SecurityConfig.java @@ -1,34 +1,48 @@ package com.bookingtour.sun.config; import com.bookingtour.sun.enums.UserRole; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration +@RequiredArgsConstructor public class SecurityConfig { - + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth - + // public pages .requestMatchers( + "/tours", "/login", "/register", + "/css/**", "/js/**", - "/images/**" + "/images/**", + "/uploads/**", + + "/login/oauth2/**", + "/oauth2/**" ) .permitAll() + .requestMatchers(HttpMethod.GET, + "/", + "/tours/**" + ).permitAll() // admin page .requestMatchers("/admin/**") .hasAnyRole(UserRole.ADMIN.name()) // client page - .requestMatchers("/") - .hasAnyRole(UserRole.ADMIN.name(), UserRole.USER.name()) + // booking phải login + .requestMatchers("/bookings/**") + .authenticated() .anyRequest() .authenticated() @@ -46,6 +60,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .invalidateHttpSession(true) .deleteCookies("JSESSIONID") .permitAll() + ) + + .oauth2Login(oauth -> oauth + .loginPage("/login") + .defaultSuccessUrl("/") + .successHandler(oAuth2LoginSuccessHandler) ); return http.build(); diff --git a/src/main/java/com/bookingtour/sun/controller/BookingController.java b/src/main/java/com/bookingtour/sun/controller/BookingController.java index 20606ea..750aad2 100644 --- a/src/main/java/com/bookingtour/sun/controller/BookingController.java +++ b/src/main/java/com/bookingtour/sun/controller/BookingController.java @@ -5,6 +5,7 @@ import com.bookingtour.sun.dto.request.PageResponse; import com.bookingtour.sun.dto.response.BookingReponse; import com.bookingtour.sun.services.BookingService; +import com.bookingtour.sun.services.CurrentUserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,6 +15,7 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.mvc.support.RedirectAttributes; @@ -24,12 +26,13 @@ @Slf4j public class BookingController { private final BookingService bookingService; + private final CurrentUserService currentUserService; @GetMapping("") - public String listBooking(@AuthenticationPrincipal UserDetails userDetails, - Model model, + public String listBooking(Model model, BookingSearchRequest request) { - String email = userDetails.getUsername(); + String email = currentUserService.getCurrentUserEmail(); + PageResponse bookings = bookingService.getMyBooking(email, request); model.addAttribute("bookings", bookings.getContent()); @@ -51,8 +54,8 @@ public String createBooking(@AuthenticationPrincipal UserDetails userDetails, redirectAttributes.addFlashAttribute("errorMessage", "Thông tin đặt tour không hợp lệ"); return "redirect:/tours/" + request.getTourId(); } - String email = userDetails.getUsername(); try { + String email = currentUserService.getCurrentUserEmail(); bookingService.createBooking(email, request); redirectAttributes.addFlashAttribute("successMessage", "Đặt tour thành công"); return "redirect:/bookings"; @@ -62,4 +65,21 @@ public String createBooking(@AuthenticationPrincipal UserDetails userDetails, return "redirect:/tours/" + request.getTourId(); } } + + @PostMapping("/{id}/cancel") + public String cancelBooking( + @PathVariable Long id, + RedirectAttributes redirectAttributes + ) { + try { + String email = currentUserService.getCurrentUserEmail(); + bookingService.cancelBooking(id, email); + redirectAttributes.addFlashAttribute("successMessage", "Hủy đặt tour thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + } + + return "redirect:/bookings"; + } } diff --git a/src/main/java/com/bookingtour/sun/controller/ClientController.java b/src/main/java/com/bookingtour/sun/controller/ClientController.java index 19dc611..9fe4ccd 100644 --- a/src/main/java/com/bookingtour/sun/controller/ClientController.java +++ b/src/main/java/com/bookingtour/sun/controller/ClientController.java @@ -1,10 +1,16 @@ package com.bookingtour.sun.controller; +import com.bookingtour.sun.dto.request.RegisterRequest; import com.bookingtour.sun.dto.response.TourResponse; import com.bookingtour.sun.services.TourService; +import com.bookingtour.sun.services.UserService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; import java.util.List; @@ -12,6 +18,7 @@ @RequiredArgsConstructor public class ClientController { private final TourService tourService; + private final UserService userService; @GetMapping("/") public String home(Model model) { @@ -26,4 +33,31 @@ public String login(Model model) { model.addAttribute("title", "Login - Tour Booking"); return "client/login"; } + + @GetMapping("/register") + public String register(Model model) { + model.addAttribute("title", "Register - Tour Booking"); + model.addAttribute("user", new RegisterRequest()); + return "client/register"; + } + + @PostMapping("/register") + public String register( + @Valid @ModelAttribute("user") RegisterRequest request, + BindingResult result, + Model model) { + + if (!request.getPassword().equals(request.getConfirmPassword())) { + model.addAttribute("passwordError", "Passwords do not match."); + return "client/register"; + } + + if (result.hasErrors()) { + return "client/register"; + } + + userService.register(request); + + return "redirect:/login?registered"; + } } diff --git a/src/main/java/com/bookingtour/sun/controller/PaymentController.java b/src/main/java/com/bookingtour/sun/controller/PaymentController.java new file mode 100644 index 0000000..1447e40 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/controller/PaymentController.java @@ -0,0 +1,73 @@ +package com.bookingtour.sun.controller; + +import com.bookingtour.sun.entity.Payment; +import com.bookingtour.sun.exception.ResourceNotFoundException; +import com.bookingtour.sun.exception.UnauthorizedException; +import com.bookingtour.sun.repository.PaymentRepository; +import com.bookingtour.sun.services.CurrentUserService; +import com.bookingtour.sun.services.PaymentService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/payments") +@RequiredArgsConstructor +public class PaymentController { + private final PaymentService paymentService; + private final CurrentUserService currentUserService; + private final PaymentRepository paymentRepository; + + + @PostMapping("/{bookingId}/create") + public String createPayment(@PathVariable Long bookingId, RedirectAttributes redirectAttributes) + { + try { + String email = currentUserService.getCurrentUserEmail(); + String url = paymentService.createPayment(bookingId, email); + return "redirect:" + url; + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Thanh toán thất bại"); + return "redirect:/bookings"; + } + } + + @GetMapping("/mock/{id}") + public String mockPayment( + @PathVariable Long id, + Model model, + RedirectAttributes redirectAttributes + ){ + try { + String email = currentUserService.getCurrentUserEmail(); + Payment payment = paymentRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Payment not found with id: " + id)); + if(!payment.getBooking().getUser().getEmail().equals(email)) { + throw new UnauthorizedException("Invalid booking"); + } + model.addAttribute("paymentId", id); + return "client/payment/mock"; + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Thanh toán thất bại"); + return "redirect:/bookings"; + } + } + + @PostMapping("/{id}/success") + public String success(@PathVariable Long id, RedirectAttributes redirectAttributes) + { + try { + String email = currentUserService.getCurrentUserEmail(); + paymentService.success(id, email); + redirectAttributes.addFlashAttribute("successMessage", "Thanh toán thành công"); + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Thanh toán thất bại"); + } + + return "redirect:/bookings"; + } +} diff --git a/src/main/java/com/bookingtour/sun/controller/TourController.java b/src/main/java/com/bookingtour/sun/controller/TourController.java index 8d89fd8..4e6feb1 100644 --- a/src/main/java/com/bookingtour/sun/controller/TourController.java +++ b/src/main/java/com/bookingtour/sun/controller/TourController.java @@ -3,17 +3,17 @@ import com.bookingtour.sun.dto.request.ClientTourSearchRequest; import com.bookingtour.sun.dto.request.EditTourRequest; import com.bookingtour.sun.dto.request.PageResponse; +import com.bookingtour.sun.dto.request.ReviewRequest; import com.bookingtour.sun.dto.response.TourResponse; import com.bookingtour.sun.services.CategoryService; +import com.bookingtour.sun.services.CurrentUserService; +import com.bookingtour.sun.services.TourReviewService; import com.bookingtour.sun.services.TourService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; @Controller @@ -23,6 +23,8 @@ public class TourController { private final CategoryService categoryService; private final TourService tourService; + private final TourReviewService reviewService; + private final CurrentUserService currentUserService; @GetMapping("") public String listTour(@ModelAttribute ClientTourSearchRequest searchForm, Model model) { @@ -52,6 +54,7 @@ public String showTour(@PathVariable Long id, Model model, RedirectAttributes re model.addAttribute("tour", request); model.addAttribute("tourImages", tourService.getTourImages(id)); model.addAttribute("tourItineraries", tourService.getTourItineraries(id)); + model.addAttribute("reviews", reviewService.getReviews(id)); return "client/tours/show"; } catch (Exception e) { @@ -60,4 +63,20 @@ public String showTour(@PathVariable Long id, Model model, RedirectAttributes re return "redirect:/tours"; } } + + @PostMapping("/{id}/reviews") + public String review( + @PathVariable Long id, + @ModelAttribute ReviewRequest request, + RedirectAttributes redirectAttributes){ + + try { + String email = currentUserService.getCurrentUserEmail(); + reviewService.createReview(id, email, request); + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Failed to review tour for viewing: " + e.getMessage()); + } + + return "redirect:/tours/"+id; + } } diff --git a/src/main/java/com/bookingtour/sun/dto/request/EditTourRequest.java b/src/main/java/com/bookingtour/sun/dto/request/EditTourRequest.java index ad99185..4ff1877 100644 --- a/src/main/java/com/bookingtour/sun/dto/request/EditTourRequest.java +++ b/src/main/java/com/bookingtour/sun/dto/request/EditTourRequest.java @@ -54,4 +54,6 @@ public class EditTourRequest { @Builder.Default @Valid private List itineraries = new ArrayList<>(); + private Double averageRating; + private Long reviewCount; } diff --git a/src/main/java/com/bookingtour/sun/dto/request/RatingSummary.java b/src/main/java/com/bookingtour/sun/dto/request/RatingSummary.java new file mode 100644 index 0000000..a8a9cd0 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/dto/request/RatingSummary.java @@ -0,0 +1,14 @@ +package com.bookingtour.sun.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class RatingSummary { + private Double averageRating; + private Long reviewCount; +} diff --git a/src/main/java/com/bookingtour/sun/dto/request/RegisterRequest.java b/src/main/java/com/bookingtour/sun/dto/request/RegisterRequest.java new file mode 100644 index 0000000..187e15a --- /dev/null +++ b/src/main/java/com/bookingtour/sun/dto/request/RegisterRequest.java @@ -0,0 +1,28 @@ +package com.bookingtour.sun.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RegisterRequest { + @NotBlank + private String username; + + @NotBlank + private String fullName; + + @Email + @NotBlank + private String email; + + private String phone; + + @Size(min = 6) + private String password; + + private String confirmPassword; +} diff --git a/src/main/java/com/bookingtour/sun/dto/request/ReviewRequest.java b/src/main/java/com/bookingtour/sun/dto/request/ReviewRequest.java new file mode 100644 index 0000000..2f4ee45 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/dto/request/ReviewRequest.java @@ -0,0 +1,9 @@ +package com.bookingtour.sun.dto.request; + +import lombok.Data; + +@Data +public class ReviewRequest { + private Integer rating; + private String comment; +} diff --git a/src/main/java/com/bookingtour/sun/dto/response/ReviewResponse.java b/src/main/java/com/bookingtour/sun/dto/response/ReviewResponse.java new file mode 100644 index 0000000..590a665 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/dto/response/ReviewResponse.java @@ -0,0 +1,21 @@ +package com.bookingtour.sun.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class ReviewResponse { + + private String username; + + private String avatar; + + private Integer rating; + + private String comment; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/bookingtour/sun/entity/Booking.java b/src/main/java/com/bookingtour/sun/entity/Booking.java index 5fe35ce..7e4b892 100644 --- a/src/main/java/com/bookingtour/sun/entity/Booking.java +++ b/src/main/java/com/bookingtour/sun/entity/Booking.java @@ -52,6 +52,10 @@ public class Booking extends BaseEntity { @Column(columnDefinition = "TEXT") private String note; + @OneToOne(mappedBy = "booking", + cascade = CascadeType.ALL) + private Payment payment; + @PrePersist public void prePersist() { if (bookingDate == null) { diff --git a/src/main/java/com/bookingtour/sun/entity/Payment.java b/src/main/java/com/bookingtour/sun/entity/Payment.java new file mode 100644 index 0000000..8f45418 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/entity/Payment.java @@ -0,0 +1,35 @@ +package com.bookingtour.sun.entity; + +import com.bookingtour.sun.enums.PaymentStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@Table(name = "payments") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Payment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "booking_id", nullable = false) + private Booking booking; + + @Column(nullable = false) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + private String paymentMethod; + + private String transactionId; +} diff --git a/src/main/java/com/bookingtour/sun/entity/TourReview.java b/src/main/java/com/bookingtour/sun/entity/TourReview.java new file mode 100644 index 0000000..697244a --- /dev/null +++ b/src/main/java/com/bookingtour/sun/entity/TourReview.java @@ -0,0 +1,41 @@ +package com.bookingtour.sun.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name="tour_reviews", + uniqueConstraints = { + @UniqueConstraint( + name="uk_user_tour_review", + columnNames={"tour_id","user_id"} + ) + }) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TourReview extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="tour_id") + private Tour tour; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id") + private User user; + + + @Column(nullable=false) + private Integer rating; + + + @Column(columnDefinition="TEXT") + private String comment; +} diff --git a/src/main/java/com/bookingtour/sun/entity/User.java b/src/main/java/com/bookingtour/sun/entity/User.java index 105d01d..bbfbd49 100644 --- a/src/main/java/com/bookingtour/sun/entity/User.java +++ b/src/main/java/com/bookingtour/sun/entity/User.java @@ -1,5 +1,6 @@ package com.bookingtour.sun.entity; +import com.bookingtour.sun.enums.LoginProvider; import com.bookingtour.sun.enums.UserRole; import com.bookingtour.sun.enums.UserStatus; import jakarta.persistence.*; @@ -24,7 +25,7 @@ public class User extends BaseEntity { @Column(nullable = false, unique = true, length = 100) private String email; - @Column(name = "password_digest", nullable = false) + @Column(name = "password_digest") private String passwordDigest; @Column(name = "full_name", length = 100) @@ -43,6 +44,10 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private UserStatus status; + @Enumerated(EnumType.STRING) + @Column(name = "login_provider", nullable = false) + private LoginProvider loginProvider; + @PrePersist protected void onCreate() { if (role == null) { @@ -51,5 +56,9 @@ protected void onCreate() { if (status == null) { status = UserStatus.ACTIVE; // Default status } + + if (loginProvider == null) { + loginProvider = LoginProvider.LOCAL; + } } } diff --git a/src/main/java/com/bookingtour/sun/enums/LoginProvider.java b/src/main/java/com/bookingtour/sun/enums/LoginProvider.java new file mode 100644 index 0000000..3bc6e0b --- /dev/null +++ b/src/main/java/com/bookingtour/sun/enums/LoginProvider.java @@ -0,0 +1,6 @@ +package com.bookingtour.sun.enums; + +public enum LoginProvider { + LOCAL, + GOOGLE +} diff --git a/src/main/java/com/bookingtour/sun/enums/PaymentStatus.java b/src/main/java/com/bookingtour/sun/enums/PaymentStatus.java new file mode 100644 index 0000000..8b7e02a --- /dev/null +++ b/src/main/java/com/bookingtour/sun/enums/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.bookingtour.sun.enums; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED, + REFUNDED +} diff --git a/src/main/java/com/bookingtour/sun/exception/DuplicateResourceException.java b/src/main/java/com/bookingtour/sun/exception/DuplicateResourceException.java new file mode 100644 index 0000000..c48acec --- /dev/null +++ b/src/main/java/com/bookingtour/sun/exception/DuplicateResourceException.java @@ -0,0 +1,5 @@ +package com.bookingtour.sun.exception; + +public class DuplicateResourceException extends RuntimeException { + public DuplicateResourceException(String message) {super(message);} +} diff --git a/src/main/java/com/bookingtour/sun/exception/UnauthorizedException.java b/src/main/java/com/bookingtour/sun/exception/UnauthorizedException.java new file mode 100644 index 0000000..368f085 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/exception/UnauthorizedException.java @@ -0,0 +1,5 @@ +package com.bookingtour.sun.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) {super(message);} +} diff --git a/src/main/java/com/bookingtour/sun/repository/PaymentRepository.java b/src/main/java/com/bookingtour/sun/repository/PaymentRepository.java new file mode 100644 index 0000000..36aa5f3 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/repository/PaymentRepository.java @@ -0,0 +1,11 @@ +package com.bookingtour.sun.repository; + +import com.bookingtour.sun.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentRepository + extends JpaRepository { + Optional findByBookingId(Long bookingId); +} diff --git a/src/main/java/com/bookingtour/sun/repository/TourReviewRepository.java b/src/main/java/com/bookingtour/sun/repository/TourReviewRepository.java new file mode 100644 index 0000000..8681d66 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/repository/TourReviewRepository.java @@ -0,0 +1,29 @@ +package com.bookingtour.sun.repository; + +import com.bookingtour.sun.dto.request.RatingSummary; +import com.bookingtour.sun.entity.TourReview; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface TourReviewRepository + extends JpaRepository { + + List findByTourIdOrderByCreatedAtDesc(Long tourId); + + boolean existsByTourIdAndUserId( + Long tourId, + Long userId + ); + + @Query(""" + SELECT + AVG(r.rating), + COUNT(r.id) + FROM TourReview r + WHERE r.tour.id = :tourId + """) + RatingSummary getRatingSummary(@Param("tourId") Long tourId); +} diff --git a/src/main/java/com/bookingtour/sun/services/BookingService.java b/src/main/java/com/bookingtour/sun/services/BookingService.java index 77e898f..ef50059 100644 --- a/src/main/java/com/bookingtour/sun/services/BookingService.java +++ b/src/main/java/com/bookingtour/sun/services/BookingService.java @@ -6,7 +6,9 @@ import com.bookingtour.sun.entity.Tour; import com.bookingtour.sun.entity.TourImage; import com.bookingtour.sun.entity.User; +import com.bookingtour.sun.enums.BookingStatus; import com.bookingtour.sun.exception.ResourceNotFoundException; +import com.bookingtour.sun.exception.UnauthorizedException; import com.bookingtour.sun.repository.BookingRepository; import com.bookingtour.sun.repository.TourRepository; import com.bookingtour.sun.repository.UserRepository; @@ -137,6 +139,23 @@ public void updateBooking(Long id, AdminEditBookingRequest request) { bookingRepository.save(booking); } + public void cancelBooking(Long bookingId, String email) { + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new ResourceNotFoundException("Booking not found")); + + // check booking owner + if(!booking.getUser().getEmail().equals(email)) { + throw new UnauthorizedException("You cannot cancel this booking"); + } + + // chỉ pending payment mới được cancel + if(booking.getStatus() != BookingStatus.PENDING_PAYMENT){ + throw new IllegalStateException("Only pending payment booking can be cancelled"); + } + booking.setStatus(BookingStatus.CANCELLED); + bookingRepository.save(booking); + } + private BookingReponse toBookingResponse( Booking booking) { diff --git a/src/main/java/com/bookingtour/sun/services/CurrentUserService.java b/src/main/java/com/bookingtour/sun/services/CurrentUserService.java new file mode 100644 index 0000000..f9aa561 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/services/CurrentUserService.java @@ -0,0 +1,33 @@ +package com.bookingtour.sun.services; + +import com.bookingtour.sun.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CurrentUserService { + + private final UserRepository userRepository; + + public String getCurrentUserEmail() { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserDetails userDetails) { + return userDetails.getUsername(); + } + + if (principal instanceof OAuth2User oAuth2User) { + return oAuth2User.getAttribute("email"); + } + + throw new IllegalStateException("User is not authenticated."); + } +} diff --git a/src/main/java/com/bookingtour/sun/services/PaymentService.java b/src/main/java/com/bookingtour/sun/services/PaymentService.java new file mode 100644 index 0000000..30b3743 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/services/PaymentService.java @@ -0,0 +1,61 @@ +package com.bookingtour.sun.services; + +import com.bookingtour.sun.entity.Booking; +import com.bookingtour.sun.entity.Payment; +import com.bookingtour.sun.enums.BookingStatus; +import com.bookingtour.sun.enums.PaymentStatus; +import com.bookingtour.sun.exception.ResourceNotFoundException; +import com.bookingtour.sun.exception.UnauthorizedException; +import com.bookingtour.sun.repository.BookingRepository; +import com.bookingtour.sun.repository.PaymentRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final BookingRepository bookingRepository; + private final PaymentRepository paymentRepository; + + @Transactional + public String createPayment(Long bookingId, String email){ + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new ResourceNotFoundException("Booking not found with id: " + bookingId)); + + if(!booking.getUser().getEmail().equals(email)){ + throw new UnauthorizedException("Invalid booking"); + } + if(booking.getStatus() != BookingStatus.PENDING_PAYMENT){ + throw new IllegalStateException("Booking cannot be paid"); + } + Payment payment = paymentRepository + .findByBookingId(bookingId) + .orElse(null); + if (payment == null) { + payment = Payment.builder() + .booking(booking) + .amount(booking.getTotalAmount()) + .status(PaymentStatus.PENDING) + .paymentMethod("ONLINE") + .build(); + } + + paymentRepository.save(payment); + + // gia lap url + return "/payments/mock/" + payment.getId(); + } + + @Transactional + public void success(Long paymentId, String email){ + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new ResourceNotFoundException("Payment not found with id: " + paymentId)); + if(!payment.getBooking().getUser().getEmail().equals(email)){ + throw new UnauthorizedException("Invalid booking"); + } + payment.setStatus(PaymentStatus.SUCCESS); + payment.getBooking().setStatus(BookingStatus.PAID); + } +} diff --git a/src/main/java/com/bookingtour/sun/services/TourReviewService.java b/src/main/java/com/bookingtour/sun/services/TourReviewService.java new file mode 100644 index 0000000..fd4e489 --- /dev/null +++ b/src/main/java/com/bookingtour/sun/services/TourReviewService.java @@ -0,0 +1,52 @@ +package com.bookingtour.sun.services; + +import com.bookingtour.sun.dto.request.ReviewRequest; +import com.bookingtour.sun.entity.Tour; +import com.bookingtour.sun.entity.TourReview; +import com.bookingtour.sun.entity.User; +import com.bookingtour.sun.exception.DuplicateResourceException; +import com.bookingtour.sun.exception.ResourceNotFoundException; +import com.bookingtour.sun.repository.TourRepository; +import com.bookingtour.sun.repository.TourReviewRepository; +import com.bookingtour.sun.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TourReviewService { + private final TourReviewRepository reviewRepository; + private final TourRepository tourRepository; + private final UserRepository userRepository; + + public void createReview( + Long tourId, + String email, + ReviewRequest request + ){ + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ResourceNotFoundException("User not found with email: " + email)); + + if(reviewRepository.existsByTourIdAndUserId(tourId, user.getId())){ + throw new DuplicateResourceException("You already reviewed this tour"); + } + Tour tour = tourRepository.findById(tourId) + .orElseThrow(() -> new ResourceNotFoundException("Tour not found with id: " + tourId)); + + TourReview review = TourReview.builder() + .tour(tour) + .user(user) + .rating(request.getRating()) + .comment(request.getComment()) + .build(); + + reviewRepository.save(review); + } + + + public List getReviews(Long tourId){ + return reviewRepository.findByTourIdOrderByCreatedAtDesc(tourId); + } +} diff --git a/src/main/java/com/bookingtour/sun/services/TourService.java b/src/main/java/com/bookingtour/sun/services/TourService.java index e497880..4124741 100644 --- a/src/main/java/com/bookingtour/sun/services/TourService.java +++ b/src/main/java/com/bookingtour/sun/services/TourService.java @@ -13,6 +13,7 @@ import com.bookingtour.sun.repository.TourImageRepository; import com.bookingtour.sun.repository.TourItineraryRepository; import com.bookingtour.sun.repository.TourRepository; +import com.bookingtour.sun.repository.TourReviewRepository; import com.bookingtour.sun.specification.TourSpecification; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -36,6 +37,7 @@ public class TourService { private final TourImageRepository tourImageRepository; private final FileStorageService fileStorageService; private final TourItineraryRepository tourItineraryRepository; + private final TourReviewRepository tourReviewRepository; @Transactional public TourResponse createTour(CreateTourRequest request) { @@ -194,6 +196,15 @@ public EditTourRequest getTourForEdit(Long id) { .build()) .toList(); request.setItineraries(itineraries); + RatingSummary rating = tourReviewRepository.getRatingSummary(id); + if(rating != null){ + request.setAverageRating(rating.getAverageRating()); + request.setReviewCount(rating.getReviewCount()); + } + else { + request.setAverageRating(0D); + request.setReviewCount(0L); + } return request; } diff --git a/src/main/java/com/bookingtour/sun/services/UserService.java b/src/main/java/com/bookingtour/sun/services/UserService.java index b11becf..3d6d602 100644 --- a/src/main/java/com/bookingtour/sun/services/UserService.java +++ b/src/main/java/com/bookingtour/sun/services/UserService.java @@ -1,6 +1,7 @@ package com.bookingtour.sun.services; import com.bookingtour.sun.dto.request.PageResponse; +import com.bookingtour.sun.dto.request.RegisterRequest; import com.bookingtour.sun.dto.request.UserSearchRequest; import com.bookingtour.sun.dto.response.UserResponse; import com.bookingtour.sun.entity.User; @@ -13,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; @@ -22,6 +24,7 @@ @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; public PageResponse searchUsers(UserSearchRequest request) { Pageable pageable = PageRequest.of( @@ -50,6 +53,17 @@ public PageResponse searchUsers(UserSearchRequest request) { .build(); } + public void register(RegisterRequest request) { + User user = User.builder() + .username(request.getUsername()) + .fullName(request.getFullName()) + .email(request.getEmail()) + .phone(request.getPhone()) + .passwordDigest(passwordEncoder.encode(request.getPassword())) + .build(); + userRepository.save(user); + } + private UserResponse mapToResponse(User user) { return UserResponse.builder() .id(user.getId()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f2ff644..0992425 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,16 @@ spring: enabled: true locations: classpath:db/migration baseline-on-migrate: true + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - email + - profile # Turn on hiddenhttpmethodfilter to support HTTP methods like PUT and DELETE in forms mvc: diff --git a/src/main/resources/db/migration/V202606291151__alter_users.sql b/src/main/resources/db/migration/V202606291151__alter_users.sql new file mode 100644 index 0000000..43fe2b4 --- /dev/null +++ b/src/main/resources/db/migration/V202606291151__alter_users.sql @@ -0,0 +1,4 @@ +ALTER TABLE users + MODIFY COLUMN password_digest VARCHAR(255) NULL; +ALTER TABLE users + ADD COLUMN login_provider VARCHAR(20) NOT NULL DEFAULT 'LOCAL'; \ No newline at end of file diff --git a/src/main/resources/db/migration/V202606291317__create_tour_reviews.sql b/src/main/resources/db/migration/V202606291317__create_tour_reviews.sql new file mode 100644 index 0000000..ca74990 --- /dev/null +++ b/src/main/resources/db/migration/V202606291317__create_tour_reviews.sql @@ -0,0 +1,27 @@ +CREATE TABLE tour_reviews ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + + tour_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + + rating INT NOT NULL, + comment TEXT, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + + CONSTRAINT fk_review_tour + FOREIGN KEY (tour_id) + REFERENCES tours(id) + ON DELETE CASCADE, + + CONSTRAINT fk_review_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT uk_user_tour_review + UNIQUE(tour_id, user_id) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V202606291424__create_payments.sql b/src/main/resources/db/migration/V202606291424__create_payments.sql new file mode 100644 index 0000000..9cd3dce --- /dev/null +++ b/src/main/resources/db/migration/V202606291424__create_payments.sql @@ -0,0 +1,16 @@ +CREATE TABLE payments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + booking_id BIGINT NOT NULL UNIQUE, + amount DECIMAL(12,2) NOT NULL, + status VARCHAR(50) NOT NULL, + payment_method VARCHAR(50), + transaction_id VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_payment_booking + FOREIGN KEY (booking_id) + REFERENCES bookings(id) + ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/main/resources/static/js/user-register.js b/src/main/resources/static/js/user-register.js new file mode 100644 index 0000000..72050b8 --- /dev/null +++ b/src/main/resources/static/js/user-register.js @@ -0,0 +1,13 @@ +$(document).ready(function () { + $(".toggle-password").click(function () { + const input = $(this).siblings("input"); + + if (input.attr("type") === "password") { + input.attr("type", "text"); + $(this).text("🙈"); + } else { + input.attr("type", "password"); + $(this).text("👁️"); + } + }); +}); diff --git a/src/main/resources/templates/client/booking/index.html b/src/main/resources/templates/client/booking/index.html index cc6a24e..f114e3b 100644 --- a/src/main/resources/templates/client/booking/index.html +++ b/src/main/resources/templates/client/booking/index.html @@ -12,6 +12,19 @@

Lịch Sử Đặt Tour

+
+ + +
+
+ +
+
@@ -81,12 +94,23 @@

-
- - +
+ + +
+
+ + +
-
diff --git a/src/main/resources/templates/client/login.html b/src/main/resources/templates/client/login.html index a574d9f..2416b94 100644 --- a/src/main/resources/templates/client/login.html +++ b/src/main/resources/templates/client/login.html @@ -8,6 +8,10 @@
+
+ + Registration successful! Please sign in. +