= Финална имплементација на кориснички сценарија До оваа фаза беа имплементирани сите предвидени кориснички сценарија, односно: ||= ID =||= Use Case =|| || 1 || Се најавува на системот || || 2 || Се регистрира на системот || || 3 || Листа сместување || || 4 || Листа превоз || || 5 || Листа ресторани || || 6 || Резервира сместување || || 7 || Резервира превоз || || 8 || Резервира ресторани || || 9 || Откажува услуга || || 10 || Пријавува сместување || || 11 || Пријавува превоз || || 12 || Пријавува ресторан || || 13 || Менаџира резервации || || 14 || Регистрира нов бизнис || || 15 || Менува понуда за сместување || || 16 || Менува понуда за превоз || || 17 || Менува понуда за ресторан || || 15 || Управува со профили || || 16 || Верифицира профили || || 17 || Внесува оценка || Во нив нема некакви поголеми промени, освен во листањето на сместувања кои ќе бидат наведени во продолжение. \\ \\ Дополнително се имплементирани следните сценарија по актери: == Ненајавен корисник ||= ID =||= Use Case =|| || 18 || Се најавува на системот преку надворешен систем || || 21 || Поврзува профил || == Систем ||= ID =||= Use Case =|| || 19 || Испраќа известување до администратори || || 20 || Испраќа известување до корисници на услуги|| == Имплементација === Се најавува на системот преку надворешен систем [[Image(new_login.png)]] Во последната верзија, со имплементација на OAuth2 клиентот, системот овозможува најава преку користење на профил од Google, Facebook или GitHub. Конфигурацијата е додадена на веќе постоечката за Spring Security, со тоа што дополнително е имплементиран CustomOAuth2AuthenticationService сервис за справување со барањата за регистрација и најава со профил од некој од претходно споментатите провајдери. {{{#!java ... .and() .oauth2Login() .loginPage("/login") .permitAll() .userInfoEndpoint(x -> x.userService(customOAuth2UserDetailService)) .successHandler(oAuth2SuccessHandler) .failureHandler(oAuth2FailureHandler) ... }}} За правилно справување со различните формати на одговор кои секоја од страниците го враќа, преку имплементација на шаблонот за развој на софтвер Factory, имплементиравме фабрика за објекти од типот OAuth2UserDetails, при што во зависност од сценариото соодветно се преземаат потребните атрибути за да може корисникот да идентификува и/или регистрира во нашата база. {{{#!java public abstract class OAuth2UserDetails { protected Map attributes; public OAuth2UserDetails(Map attributes) { this.attributes = attributes; } public abstract String getName(); public abstract String getEmail(); } }}} {{{#!java public class OAuth2GoogleUser extends OAuth2UserDetails{ public OAuth2GoogleUser(Map attributes) { super(attributes); } @Override public String getName() { return (String) attributes.get("name"); } @Override public String getEmail() { return (String) attributes.get("email"); } } }}} {{{#!java public class OAuth2GitHubUser extends OAuth2UserDetails{ public OAuth2GitHubUser(Map attributes) { super(attributes); } @Override public String getName() { return (String) attributes.get("name"); } @Override public String getEmail() { return (String) attributes.get("login"); } } }}} {{{#!java public class OAuth2FacebookUser extends OAuth2UserDetails{ public OAuth2FacebookUser(Map attributes) { super(attributes); } @Override public String getName() { return (String) attributes.get("name"); } @Override public String getEmail() { return (String) attributes.get("email"); } } }}} {{{#!java public class OAuth2UserDetailsFactory { public static OAuth2UserDetails createOAuth2UserDetails (String registrationId, Map attributes) { if(registrationId.equals(Providers.google.name())) { return new OAuth2GoogleUser(attributes); } else if(registrationId.equals(Providers.facebook.name())) { return new OAuth2FacebookUser(attributes); } else if(registrationId.equals(Providers.github.name())) { return new OAuth2GitHubUser(attributes); } else { throw new RuntimeException("Login with this provider is not supported!"); } } } }}} Објектот од ваков тип соодветно се креира во сервисот за автентикација преку повик на статичкиот метод. {{{#!java @Service public class CustomOAuth2UserDetailService extends DefaultOAuth2UserService { private final UsersDao usersDao; public CustomOAuth2UserDetailService(UsersDao usersDao) { this.usersDao = usersDao; } @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); try { return checkOAuth2User(userRequest, oAuth2User); } catch (AuthenticationException e) { throw e; } catch (Exception ex) { throw ex; } } private OAuth2User checkOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { OAuth2UserDetails oAuth2UserDetails = OAuth2UserDetailsFactory .createOAuth2UserDetails(oAuth2UserRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes()); if(ObjectUtils.isEmpty(oAuth2UserRequest)) { throw new RuntimeException("Cannot identifty OAuth2 user!"); } User user = usersDao.findByUsernameAndProvider( oAuth2UserDetails.getEmail(), oAuth2UserRequest.getClientRegistration().getRegistrationId()); User userDetails = null; if(user != null) { userDetails = user; userDetails = updateOAuth2UserDetail(userDetails, oAuth2UserDetails); } else { userDetails = registerOAuth2UserDetail(oAuth2UserRequest, oAuth2UserDetails); } return new OAuth2UserDetailsCustom( userDetails.getUserID(), userDetails.getUsername(), userDetails.getPassword(), Collections.singletonList(new SimpleGrantedAuthority(userDetails.getRole().getRoleName())) ); } public User registerOAuth2UserDetail(OAuth2UserRequest oAuth2UserRequest, OAuth2UserDetails oAuth2UserDetails) { Role r = usersDao.findById(1L); User user = new User(); user.setName(Objects.requireNonNullElse(oAuth2UserDetails.getName(), "")); user.setEmail(oAuth2UserDetails.getEmail()); user.setProvider(oAuth2UserRequest.getClientRegistration().getRegistrationId()); user.setRole(r); return usersDao.updateUser(user); } public User updateOAuth2UserDetail(User user, OAuth2UserDetails oAuth2UserDetails) { user.setEmail(oAuth2UserDetails.getEmail()); return usersDao.mergeUser(user); } } }}} По креирањето на објект од овој тип, се проверува во базата дали евентуално постои ваков корисник и доколку не, истиот се регистрира. Откако ќе се утврди неговиот статус, функцијата враќа објект од типот OAuth2UserDetailsCustom кој го имплементира UserDetails интерфејсот од Java со што се овозможува да поминува низ филтрите и да се третира исто како и регуларно најавен корисник директно на системот. Доколку најавата е успешна, како и претходно, корисникот се пренасочува кон React апликацијата. {{{#!java @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { getRedirectStrategy().sendRedirect(request, response, "http://localhost:3000/login-callback"); } }}} === Поврзува профил [[Image(connect_acc.png)]] Доколку корисникот сака да поврзе негов профил со друг профил регистриран на нашиот систем, тоа може да го направи на оваа страница со коректно внесување на корисничкото име и лозинката. Истите се испраќаат преку POST барање до серверската страна каде контролерот кој ги пречекува го повикува методот во сервисот во кој внесената лозинка, преку PasswordEncoder-от се проверува со хешираната која се чува во базата и доколку се точни ги поврзува профилите. Ова е имплементирано на тој начин што ентитетот User е во M:N релација сам со себе, односно за секој User се чува листа од User - connectedAccounts. {{{#!java @PostMapping("/users/{id}/connect") public ResponseEntity connectAccount(@PathVariable Long id, @RequestParam String username, @RequestParam String password) { usersManager.connectAccount(id, username, password); return new ResponseEntity<>(HttpStatus.OK); } }}} {{{#!java @Override public void connectAccount(Long id, String username, String password) { User u1 = findUserByID(id); User u2 = (User) loadUserByUsername(username); if(passwordEncoder.matches(password, u2.getPassword())) { u1.addConnectedUser(u2); } usersDao.updateUser(u1); } }}} Откако ќе заврши оваа постапка, најавениот корисник ќе има пристап и до ресурсите на профилот со кој е поврзан. == Систем === Испраќа известување до администратори Оваа функционалност предвидува испраќања на нотификација/потсетник до администарторите во случај кога ќе се соберат повеќе профили/фирми за одобрување. За ова да функционира, потребно беше да имплементираме сервис за испраќање пораки, кој ја користи Spring Email библиотеката. {{{#!java @Service public class MailingServiceImpl implements MailingService { private final JavaMailSender emailSender; public MailingServiceImpl(JavaMailSender emailSender) { this.emailSender = emailSender; } @Override public void sendMail(String to, String subject, String messageText) { SimpleMailMessage mail = new SimpleMailMessage(); mail.setFrom("TourMate"); mail.setTo(to); mail.setSubject(subject); mail.setText(messageText); emailSender.send(mail); } } }}} Оваа проверка се прави во одредено време, секој понеделник на полноќ и доколку постојат фирми кои не се одобрени, се испраќа порака до сите администратори како потсетување. За да може во позадина да се извршуваат вакви задчаи потребно беше апликацијата да ја анотираме со @EnableScheduling, а компонентата изгледа вака: {{{#!java @Component public class MailJobs { @Autowired private BusinessManager businessManager; @Autowired private UsersManager usersManager; @Autowired private MailingService mailingService; @Scheduled(cron = "0 0 * * * MON") public void execute() { List unapprovedBusinesses = businessManager.getUnapprovedBusinesses(); if(!unapprovedBusinesses.isEmpty()) { List admins = usersManager.getAdmins().stream().filter(x -> x.getEmail().contains("@")).toList(); String subject = "Бизниси кои чекаат на одобрување"; String message = "Почитувани,\n\nВе известуваме дека постојат повеќе бизнис кои чекаат на Ваша одлука за одобрување.\n\nСо почит,\n\nTourMate"; admins.forEach(x -> mailingService.sendMail(x.getEmail(), subject, message)); } } } }}} === Испраќа известување до корисници на услуги За имплементација на оваа функционалност го користиме сервисот за испраќање пораки наведен погоре и дополнително имплементиран Listener и Event. Spring претставува event-driven архитектура, односно им овозможува на компононетите да комуницираат преку пропагација на настани во улога на publisher и subscriber. Овој механизам е типичен пример за примена на шаблонот Observer. И покарј тоа што голем дел од предефинираните компоненти во Spring комуницираат на овој начин, направивме custom имплементација на овој шаблон за ова сценарио. {{{#!java public abstract class EmailEvent extends ApplicationEvent { protected EventType eventType; protected User user; protected String subject; protected String message; public EmailEvent(User user) { super(user); this.user = user; } public User getUser() { return user; } public void setUser(User user) { this.user = user; } public EventType getEventType() { return eventType; } public void setEventType(EventType eventType) { this.eventType = eventType; } public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } }}} Имено, при регистрација на нов корисник, нова резервација, одобрување на профил или фирма се исплаува соодветен настан, објект кој наследува од EmailEvent и во конструкторот се справува со subject и message зависнот од типот. Овој настан го пречекува новодефнираниот listener и соодветно се извршува логиката за испраќање на порака. {{{#!java public class OnRegistrationSuccessEvent extends EmailEvent { private static final long serialVersionUID = 1L; private User user; public OnRegistrationSuccessEvent(User user) { super(user); this.eventType = EventType.REGISTRATION; this.subject = "TourMate - Успешна регистрација"; this.message = "Драг кориснику,\n\nВе известуваме дека Вашата регистрација на апликацијата TourMate е успешна. За да можете да го користите профилот, потребно е истиот да е одобрен од страна на администраторот за што ќе добиете дополнителна потврда на оваа адреса.\n\n\nСо почит,\nTourMate"; } } }}} {{{#!java @Override @Transactional public void approveUserProfile(User u) { u.setEnabled(true); em.persist(u); eventPublisher.publishEvent(new OnProfileEnabledEvent(u)); } }}} {{{#!java @Component public class RegistrationEmailListener implements ApplicationListener { @Autowired private Environment environment; @Autowired private MailingService mailingService; @Override public void onApplicationEvent(EmailEvent event) { mailingService.sendMail(event.getUser().getEmail(), event.getSubject(), event.getMessage()); } } }}} === Листа сместувања Мала промена има во приказот на резултатите од ова корисничко сценарио. Врз основа на некои основни статистики, хотелите кои во дадениот период се наоѓаат во топ 5 според бројот на резервации се означени со беџ „Најпопуларен“ и позадина во друга боја, додека пак оние кои имаат малку или немаат воопшто резервација, добиваат случајно една од ознаките „специјална цена“, „последна соба“ или „достапно само денес“ како извесен кавзи-маркетиншки трик. Промена за ова е направена само во сервисот кој го враќа DTO-от за хотелот, каде се додадени проверки за тоа во која категорија припаѓа хотелот. {{{#!java @Override public List getRoomsAvailibilityByDateAndLocation(String hotelLocation, Date dateFrom, Date dateTo, int numberOfBeds, Boolean flexible) { long numberOfNights = Duration.between(dateFrom.toInstant(), dateTo.toInstant()).toDays(); List hotels = getHotelsByLocation(hotelLocation); List hotelRoomReservations = getReservationsInPeriod(hotelLocation, dateFrom, dateTo); List hotelsWithReservations = hotelRoomReservations.stream().map(x -> x.getHotelRoom().getHotel()).toList(); List mostReservedHotels = hotelRoomReservations.stream() .collect(Collectors.groupingBy(x -> x.getHotelRoom().getHotel(), Collectors.counting())).entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(5) .map(Map.Entry::getKey) .toList(); List leastReservedHotels = hotelRoomReservations.stream() .collect(Collectors.groupingBy(x -> x.getHotelRoom().getHotel(), Collectors.counting())).entrySet().stream() .sorted(Map.Entry.comparingByValue()) .limit(5) .map(Map.Entry::getKey) .toList(); List hotelsWithoutReservations = hotels.stream().filter(x -> !hotelsWithReservations.contains(x)).toList(); List hotelsToBeMarketed = new ArrayList<>(); hotelsToBeMarketed.addAll(hotelsWithoutReservations); hotelsToBeMarketed.addAll(leastReservedHotels); List roomsAvailible = hotelDao.getRoomsAvailibilityByDateAndLocation(hotelLocation, dateFrom, dateTo, numberOfBeds, flexible); Map> roomsByHotels = roomsAvailible.stream().collect(Collectors.groupingBy(x -> x.getHotelRoom().getHotel())); List hotelsList = roomsByHotels.keySet().stream() .map(x -> new HotelDto( x.getHotelId(), x.getHotelName(), x.getHotelDescripiton(), x.getHotelLocation(), x.getHotelEDBS(), x.getParking(), x.getPetFriendly(), x.getInternetAvailable(), roomsByHotels.get(x).stream().mapToDouble(y -> y.getHotelRoom().getPrice()).min().getAsDouble() * numberOfNights, roomsByHotels.get(x), getReviewsForHotel(x.getHotelId()), getReviewsForHotel(x.getHotelId()).stream().mapToDouble(Reviews::getNumStar).average().orElse(0), getHotelImages(x.getHotelId()), mostReservedHotels.contains(x), hotelsToBeMarketed.contains(x) )).toList(); return hotelsList; } }}}