diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 8db618d..5e4da4c 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -51,4 +51,8 @@ jobs: -e CORS_ALLOWED_ORIGINS="${{vars.FRONTEND_URL}}" -e JWT_SECRET_ACCESS="${{ secrets.JWT_SECRET_ACCESS }}" -e JWT_SECRET_REFRESH="${{ secrets.JWT_SECRET_REFRESH }}" + -e INFOBIP_API_KEY="${{ secrets.INFOBIP_API_KEY }}" + -e INFOBIP_BASE_URL="${{ secrets.INFOBIP_BASE_URL }}" + -e INFOBIP_SENDER_EMAIL="${{ secrets.INFOBIP_SENDER_EMAIL }}" + -e INFOBIP_PHONE_NUMBER="${{ secrets.INFOBIP_PHONE_NUMBER }}" --name rimatch-backend-container dominikkovacevic/rimatch diff --git a/backend/pom.xml b/backend/pom.xml index e9540cd..249a2ad 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -52,6 +52,11 @@ 0.12.3 runtime + + com.infobip + infobip-api-java-client + 4.0.0 + org.springframework.security diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/configuration/AppConfiguration.java b/backend/src/main/java/com/rimatch/rimatchbackend/configuration/AppConfiguration.java index f81145a..115c539 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/configuration/AppConfiguration.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/configuration/AppConfiguration.java @@ -8,6 +8,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.EnableMongoAuditing; + + +import java.util.Arrays; import java.util.Collections; @Configuration @@ -25,7 +28,7 @@ FilterRegistrationBean jwtFilterRegistrationBean(){ final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(jwtAuthenticationFilter); - registrationBean.setUrlPatterns(Collections.singletonList("/api/users/*")); + registrationBean.setUrlPatterns(Arrays.asList("/api/users/*","/api/match/*")); registrationBean.setOrder(1); return registrationBean; diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java b/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java new file mode 100644 index 0000000..8dc7b13 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java @@ -0,0 +1,94 @@ +package com.rimatch.rimatchbackend.controller; + +import com.infobip.ApiException; +import com.rimatch.rimatchbackend.dto.DisplayUserDto; +import com.rimatch.rimatchbackend.dto.MatchDto; +import com.rimatch.rimatchbackend.lib.InfobipClient; +import com.rimatch.rimatchbackend.model.Match; +import com.rimatch.rimatchbackend.model.User; +import com.rimatch.rimatchbackend.service.MatchService; +import com.rimatch.rimatchbackend.service.UserService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("api/match") +public class MatchController { + + @Autowired + MatchService matchService; + + @Autowired + UserService userService; + + /*@Autowired + private SendEmailLib sendEmailLib;*/ + + @Autowired + private InfobipClient infobipClient; + + @GetMapping("/potential") + public List getPotentinalMatch(HttpServletRequest request){ + String authToken = request.getHeader("Authorization"); + User user = userService.getUserByToken(authToken); + List list = matchService.findPotentialMatches(user); + return list; + } + + @PostMapping("/accept") + public ResponseEntity accept(HttpServletRequest request, @Valid @RequestBody MatchDto matchDto) throws ApiException { + String authToken = request.getHeader("Authorization"); + User user = userService.getUserByToken(authToken); + userService.insertToSeenUserIds(user,matchDto.getUserId()); + + Optional matchedUser = userService.getUserById(matchDto.getUserId()); + + Match match = matchService.findMatch(user.getId(),matchedUser.get().getId()); + + if(match != null){ + + var recepients = new ArrayList<>(List.of("dominikkovacevic6@gmail.com")); + var string = String.format("Hello %s you just matched with %s %s!",matchedUser.get().getFirstName(),user.getFirstName(),user.getLastName()); + infobipClient.sendEmail(recepients,"You got a match!", string); + infobipClient.sendSms(string); + return ResponseEntity.ok(matchService.finishMatch(match,true)); + } + + return ResponseEntity.ok(matchService.saveMatch(user.getId(),matchDto.getUserId())); + } + + @PostMapping("/reject") + public ResponseEntity reject(HttpServletRequest request, @Valid @RequestBody MatchDto matchDto){ + String authToken = request.getHeader("Authorization"); + User user = userService.getUserByToken(authToken); + + userService.insertToSeenUserIds(user,matchDto.getUserId()); + Optional matchedUser = userService.getUserById(matchDto.getUserId()); + matchedUser.ifPresent(value -> userService.insertToSeenUserIds(value, user.getId())); + + Match match = matchService.findMatch(user.getId(),matchedUser.get().getId()); + + if(match != null){ + return ResponseEntity.ok(matchService.finishMatch(match,false)); + } + + return ResponseEntity.ok("Test"); + } + + // all matches for user sending the reuqest + @GetMapping("/all") + public ResponseEntity> getAllMatches(HttpServletRequest request){ + String authToken = request.getHeader("Authorization"); + User user = userService.getUserByToken(authToken); + List list = matchService.getAllSuccessfulMatchedUsers(user); + return ResponseEntity.ok(list); + } +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/controller/UserController.java b/backend/src/main/java/com/rimatch/rimatchbackend/controller/UserController.java index 23e44ef..6f546e2 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/controller/UserController.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/controller/UserController.java @@ -5,6 +5,7 @@ import com.rimatch.rimatchbackend.service.UserService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -14,6 +15,9 @@ import java.util.HashMap; import java.util.Map; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + @RestController @RequestMapping("api/users") @@ -22,17 +26,17 @@ public class UserController { @Autowired private UserService userService; - @GetMapping("/potentional") - public ResponseEntity getPotentinalMatch(HttpServletRequest request){ + @GetMapping("/potential") + public ResponseEntity getPotentinalMatch(HttpServletRequest request) { String authToken = request.getHeader("Authorization"); /* - Replace with something that doesn't query a database but ok for now - */ + * Replace with something that doesn't query a database but ok for now + */ User user = userService.getUserByToken(authToken); - User randomUser = userService.getRandomUser(user.getEmail(),user.getPreferences().getPartnerGender()); + var randomUsers = userService.getRandomUser(user.getEmail(), user.getPreferences().getPartnerGender()); - if (randomUser != null) { - return new ResponseEntity<>(randomUser, HttpStatus.OK); + if (randomUsers != null) { + return new ResponseEntity<>(randomUsers, HttpStatus.OK); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/dto/DisplayUserDto.java b/backend/src/main/java/com/rimatch/rimatchbackend/dto/DisplayUserDto.java new file mode 100644 index 0000000..c9c50a2 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/dto/DisplayUserDto.java @@ -0,0 +1,36 @@ +package com.rimatch.rimatchbackend.dto; + +import com.rimatch.rimatchbackend.model.User; +import lombok.Data; + +@Data +public class DisplayUserDto { + + private String id; + + private String firstName; + + private String lastName; + + private String description; + + private String profileImageUrl; + + private String location; + + private Character gender; + + private int age; + + public void initDisplayUser(User user){ + this.id = user.getId(); + this.firstName = user.getFirstName(); + this.lastName = user.getLastName(); + this.description = user.getDescription(); + this.profileImageUrl = user.getProfileImageUrl(); + this.location = user.getLocation(); + this.gender = user.getGender(); + this.age = user.getAge(); + } + +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/dto/MatchDto.java b/backend/src/main/java/com/rimatch/rimatchbackend/dto/MatchDto.java new file mode 100644 index 0000000..d1980f3 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/dto/MatchDto.java @@ -0,0 +1,15 @@ +package com.rimatch.rimatchbackend.dto; + +import jakarta.validation.Valid; +import lombok.Getter; +import lombok.NonNull; + +@Getter +public class MatchDto { + + @Valid + + @NonNull + private String userId; + +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/dto/RegisterDto.java b/backend/src/main/java/com/rimatch/rimatchbackend/dto/RegisterDto.java index a36205c..fc3438e 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/dto/RegisterDto.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/dto/RegisterDto.java @@ -7,7 +7,6 @@ import lombok.Data; import lombok.Getter; -@Getter @Data public class RegisterDto { @NotNull(message = "Email field cannot be null!") diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/lib/InfobipClient.java b/backend/src/main/java/com/rimatch/rimatchbackend/lib/InfobipClient.java new file mode 100644 index 0000000..2d9fd38 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/lib/InfobipClient.java @@ -0,0 +1,94 @@ +package com.rimatch.rimatchbackend.lib; + +import com.infobip.*; +import com.infobip.api.EmailApi; +import com.infobip.api.SmsApi; +import com.infobip.model.SmsAdvancedTextualRequest; +import com.infobip.model.SmsDestination; +import com.infobip.model.SmsResponse; +import com.infobip.model.SmsTextualMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class InfobipClient { + + @Value("${infobip.api-key}") + private String API_KEY; + + @Value("${infobip.base-url}") + private String BASE_URL; + + // Sender email address must be verified on the infobip portal for your account + @Value("${infobip.sender-email}") + private String SENDER_EMAIL_ADDRESS; + + @Value("${infobip.phone-number}") + private String PHONE_NUMBER; + + private String getSenderEmail() { + return String.format("RiMatchApp <%s>", SENDER_EMAIL_ADDRESS); + } + + private EmailApi initEmailApi() { + return new EmailApi(initApiClient()); + } + + private SmsApi initSmsApi(){ + return new SmsApi(initApiClient()); + } + + private ApiClient initApiClient(){ + return ApiClient.forApiKey(ApiKey.from(API_KEY)).withBaseUrl(BaseUrl.from(BASE_URL)).build(); + } + + public void sendSms(String text){ + + var smsApi = initSmsApi(); + + SmsTextualMessage smsMessage = new SmsTextualMessage() + .from("InfoSMS") + .addDestinationsItem(new SmsDestination().to(PHONE_NUMBER)) + .text(text); + + SmsAdvancedTextualRequest smsMessageRequest = new SmsAdvancedTextualRequest() + .messages(List.of(smsMessage)); + + smsApi.sendSmsMessage(smsMessageRequest) + .executeAsync(new ApiCallback() { + @Override + public void onSuccess(SmsResponse result, int responseStatusCode, Map> responseHeaders) { + + } + @Override + public void onFailure(ApiException exception, int responseStatusCode, Map> responseHeaders) { + } + }); + } + + public void sendEmail(List recepientEmailAddress, String subject, String text) throws ApiException { + var sendEmailApi = initEmailApi(); + + + + try { + var emailResponse = sendEmailApi.sendEmail(recepientEmailAddress) + .from(getSenderEmail()) + .subject(subject) + .text(text) + .execute(); + + System.out.println("Response body: " + emailResponse); + + var reportsResponse = sendEmailApi.getEmailDeliveryReports().execute(); + System.out.println(reportsResponse.getResults()); + } catch (ApiException e) { + System.out.println("HTTP status code: " + e.responseStatusCode()); + System.out.println("Response body: " + e.rawResponseBody()); + throw e; + } + } +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/model/Match.java b/backend/src/main/java/com/rimatch/rimatchbackend/model/Match.java new file mode 100644 index 0000000..bbc7bd5 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/model/Match.java @@ -0,0 +1,27 @@ +package com.rimatch.rimatchbackend.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NonNull; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "matches") +@Data +public class Match { + + @Id + private String id; + + @NonNull + @NotBlank + private String firstUserId; + + @NonNull + @NotBlank + private String secondUserId; + + private boolean accepted = false; + + private boolean finished = false; +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/model/Preferences.java b/backend/src/main/java/com/rimatch/rimatchbackend/model/Preferences.java index b017748..d48008f 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/model/Preferences.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/model/Preferences.java @@ -2,8 +2,6 @@ import lombok.*; @Data -@Getter -@Setter public class Preferences { private int ageGroupMin; diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/model/User.java b/backend/src/main/java/com/rimatch/rimatchbackend/model/User.java index 5025488..cd16c77 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/model/User.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/model/User.java @@ -10,12 +10,12 @@ import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; +import java.util.ArrayList; import java.util.Date; +import java.util.List; @Document(collection = "users") @Data -@Getter -@Setter public class User { @Valid @@ -62,6 +62,8 @@ public class User { private Preferences preferences; + private List seenUserIds = new ArrayList<>(); + private Date lastSeen; @CreatedDate @@ -81,5 +83,4 @@ public User(@NonNull String email, @NonNull String firstName, @NonNull String la this.age = age; } -} - +} \ No newline at end of file diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/repository/MatchRepository.java b/backend/src/main/java/com/rimatch/rimatchbackend/repository/MatchRepository.java new file mode 100644 index 0000000..1740c52 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/repository/MatchRepository.java @@ -0,0 +1,12 @@ +package com.rimatch.rimatchbackend.repository; + +import com.rimatch.rimatchbackend.model.Match; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface MatchRepository extends MongoRepository { + + Optional findByFirstUserIdAndSecondUserId(String firstUserId,String secondUserId); + +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java b/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java new file mode 100644 index 0000000..01fb929 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java @@ -0,0 +1,119 @@ +package com.rimatch.rimatchbackend.service; + +import com.rimatch.rimatchbackend.dto.DisplayUserDto; +import com.rimatch.rimatchbackend.model.Match; +import com.rimatch.rimatchbackend.model.User; +import com.rimatch.rimatchbackend.repository.MatchRepository; +import com.rimatch.rimatchbackend.repository.UserRepository; +import com.rimatch.rimatchbackend.util.DisplayUserConverter; + +import lombok.Builder; +import lombok.Getter; + +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.ComparisonOperators; +import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.aggregation.Fields; +import org.springframework.data.mongodb.core.aggregation.LookupOperation; +import org.springframework.data.mongodb.core.aggregation.MatchOperation; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class MatchService { + + private final MongoTemplate mongoTemplate; + + @Autowired + UserRepository userRepository; + + @Autowired + MatchRepository matchRepository; + + @Autowired + public MatchService(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public Match saveMatch(String id1, String id2) { + return matchRepository.save(new Match(id1, id2)); + } + + public Match findMatch(String user1, String user2) { + Optional match1 = matchRepository.findByFirstUserIdAndSecondUserId(user1, user2); + + if (match1.isPresent()) { + return match1.get(); + } + + Optional match2 = matchRepository.findByFirstUserIdAndSecondUserId(user2, user1); + + return match2.orElse(null); + + } + + public Match finishMatch(Match match, boolean status) { + match.setFinished(true); + match.setAccepted(status); + return matchRepository.save(match); + } + + public List findPotentialMatches(User user) { + + return DisplayUserConverter.convertToDtoList(mongoTemplate.aggregate( + Aggregation.newAggregation( + + Aggregation.match( + Criteria.where("active").is(true) + .and("_id").nin(user.getSeenUserIds()) + .and("age").lte(user.getPreferences().getAgeGroupMax()) + .and("gender").is(user.getPreferences().getPartnerGender()) + .and("location").is(user.getLocation()) + .and("email").ne(user.getEmail())), + // Second age parametar needs to defined separatenly because of limitations of + // the org.bson.Document + Aggregation.match(Criteria.where("age").gte(user.getPreferences().getAgeGroupMin())), + Aggregation.limit(10)), + "users", User.class).getMappedResults()); + } + + public List getAllSuccessfulMatchedUsers(User user) { + MatchOperation matchOperation = Aggregation.match( + Criteria.where("finished").is(true) + .and("accepted").is(true) + .andOperator( + new Criteria().orOperator( + Criteria.where("firstUserId").is(user.getId().toString()), + Criteria.where("secondUserId").is(user.getId().toString())))); + + ProjectionOperation projectionOperation = Aggregation.project().and( + ConditionalOperators + .when(ComparisonOperators.Eq.valueOf("firstUserId").equalToValue(user.getId().toString())) + .thenValueOf("secondUserId") + .otherwiseValueOf("firstUserId")) + .as("matchedUserId"); + + Aggregation aggregation = Aggregation.newAggregation(matchOperation, projectionOperation); + + List matchedIds = mongoTemplate.aggregate(aggregation, "matches", MatchedUserIdDTO.class).getMappedResults(); + + List matchedUserIds = matchedIds.stream() + .map(matchedUserDTO -> matchedUserDTO.getMatchedUserId()) + .collect(Collectors.toList()); + + return DisplayUserConverter.convertToDtoList(userRepository.findAllById(matchedUserIds)); + } + + @Getter + private static class MatchedUserIdDTO { + private String matchedUserId; + } +} diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/service/UserService.java b/backend/src/main/java/com/rimatch/rimatchbackend/service/UserService.java index 583b073..57366c5 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/service/UserService.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/service/UserService.java @@ -12,8 +12,6 @@ import jakarta.servlet.http.Cookie; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; @@ -29,6 +27,7 @@ @Service public class UserService { private final MongoTemplate mongoTemplate; + @Autowired private UserRepository userRepository; /* @@ -120,6 +119,8 @@ public User getUserByToken(String token){ return userRepository.findByEmail(jwtUtils.extractSubject(token, TokenType.ACCESS)); } + + public String refreshAccessToken(String token)throws IllegalArgumentException,JwtException { try { Claims claims = jwtUtils.validateToken(token, TokenType.REFRESH); @@ -134,6 +135,11 @@ public User getUserByEmail(String email){ return userRepository.findByEmail(email); } + public Optional getUserById(String id){ + return userRepository.findById(id); + } + + public User getRandomUser(String currentUserEmail,char genderPreference) { // Exclude the current user List users = mongoTemplate.aggregate( @@ -146,5 +152,11 @@ public User getRandomUser(String currentUserEmail,char genderPreference) { "users", User.class).getMappedResults(); return users.isEmpty() ? null : users.get(0); } + + public void insertToSeenUserIds(User user, String id){ + user.getSeenUserIds().add(id); + userRepository.save(user); + } + // add more methods as per your requirements } diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/util/DisplayUserConverter.java b/backend/src/main/java/com/rimatch/rimatchbackend/util/DisplayUserConverter.java new file mode 100644 index 0000000..6848502 --- /dev/null +++ b/backend/src/main/java/com/rimatch/rimatchbackend/util/DisplayUserConverter.java @@ -0,0 +1,30 @@ +package com.rimatch.rimatchbackend.util; + +import com.rimatch.rimatchbackend.dto.DisplayUserDto; +import com.rimatch.rimatchbackend.model.User; + +import java.util.List; +import java.util.stream.Collectors; + +public class DisplayUserConverter { + + public static DisplayUserDto convertToDto(User user) { + DisplayUserDto displayUserDto = new DisplayUserDto(); + displayUserDto.setId(user.getId()); + displayUserDto.setFirstName(user.getFirstName()); + displayUserDto.setLastName(user.getLastName()); + displayUserDto.setDescription(user.getDescription()); + displayUserDto.setProfileImageUrl(user.getProfileImageUrl()); + displayUserDto.setLocation(user.getLocation()); + displayUserDto.setGender(user.getGender()); + displayUserDto.setAge(user.getAge()); + + return displayUserDto; + } + + public static List convertToDtoList(List users) { + return users.stream() + .map(DisplayUserConverter::convertToDto) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 1e32b1c..b08e306 100644 --- a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -13,5 +13,25 @@ "name": "cors.allowed-origins", "type": "java.lang.String[]", "description": "A description for 'cors.allowed-origins'" + }, + { + "name": "infobip.base-url", + "type": "java.lang.String", + "description": "A description for 'infobip.base-url'" + }, + { + "name": "infobip.api-key", + "type": "java.lang.String", + "description": "A description for 'infobip.api-key'" + }, + { + "name": "infobip.sender-email", + "type": "java.lang.String", + "description": "A description for 'infobip.sender-email'" + }, + { + "name": "infobip.phone-number", + "type": "java.lang.String", + "description": "A description for 'infobip.phone-number'" } ]} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 9543e1d..cc55412 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -4,6 +4,10 @@ spring.data.mongodb.auto-index-creation=true jwt.secret.access=${JWT_SECRET_ACCESS} jwt.secret.refresh=${JWT_SECRET_REFRESH} cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5173} +infobip.api-key=${INFOBIP_API_KEY} +infobip.base-url=${INFOBIP_BASE_URL} +infobip.sender-email=${INFOBIP_SENDER_EMAIL} +infobip.phone-number=${INFOBIP_PHONE_NUMBER} springdoc.packages-to-scan=com.rimatch.rimatchbackend springdoc.paths-to-match=/api/** \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 56bae30..7963a1e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ RiMatch -
+
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index e48c39e..0021f51 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,25 +1,7 @@ +import type { User } from "@/types/User"; +import type { LoginData, RegisterData, TokenResponse } from "@/types/Auth"; import { axiosPublic } from "./config/axios"; import { UseMutationOptions, useMutation } from "@tanstack/react-query"; -import { User } from "./users"; - -interface TokenResponse { - token: string; - active: boolean; -} - -interface RegisterData { - email: string; - password: string; - firstName: string; - lastName: string; - gender: string; - age: number; -} - -interface LoginData { - email: string; - password: string; -} const AuthService = { useLogin: ( diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index c6f0217..d7600e3 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -1,44 +1,14 @@ import useAuth from "@/hooks/useAuth"; import useAxiosPrivate from "@/hooks/useAxiosPrivate"; +import type { Match, MatchData } from "@/types/Match"; +import type { PreferencesInitData, ProjectedUser, User } from "@/types/User"; import { UseMutationOptions, useMutation, useQuery, + useQueryClient, } from "@tanstack/react-query"; -export interface User { - id: string; - email: string; - firstName: string; - lastName: string; - hashedPassword: string; - gender: string; - age: number; - active: boolean; - description: string; - profileImageUrl: string; - phoneNumber: string; - location: string; - preferences: UserPreferences; - lastSeen: string; - createdAt: string; - updatedAt: string; -} - -interface UserPreferences { - ageGroupMin: number; - ageGroupMax: number; - partnerGender: string; -} - -export interface PreferencesInitData { - description: string; - profileImageUrl: string; - phoneNumber: string; - location: string; - preferences: UserPreferences; -} - export const UsersService = { useGetCurrentUser() { const { auth } = useAuth(); @@ -68,4 +38,66 @@ export const UsersService = { ...mutationOptions, }); }, + + useGetPotentailUsers: () => { + const axios = useAxiosPrivate(); + return useQuery({ + queryKey: ["UsersService.getPotentialUsers"], + queryFn: () => axios.get("/match/potential").then((res) => res.data), + staleTime: Infinity, + }); + }, + + useAcceptMatch: ( + mutationOptions?: Omit< + UseMutationOptions, + "mutationFn" + > + ) => { + const axios = useAxiosPrivate(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const response = await axios.post("/match/accept", data); + return response.data; + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["UsersService.getPotentialUsers"], + }); + }, + ...mutationOptions, + }); + }, + + useRejectMatch: ( + mutationOptions?: Omit< + UseMutationOptions, + "mutationFn" + > + ) => { + const axios = useAxiosPrivate(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const response = await axios.post("/match/reject", data); + return response.data; + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["UsersService.getPotentialUsers"], + }); + }, + ...mutationOptions, + }); + }, + + useGetMatches: () => { + const axios = useAxiosPrivate(); + return useQuery({ + queryKey: ["UsersService.getMatches"], + queryFn: () => axios.get("/match/all").then((res) => res.data), + staleTime: Infinity, + }); + }, }; diff --git a/frontend/src/components/MatchCard.tsx b/frontend/src/components/MatchCard.tsx index f15641f..af9c2b4 100644 --- a/frontend/src/components/MatchCard.tsx +++ b/frontend/src/components/MatchCard.tsx @@ -1,36 +1,68 @@ import ClearIcon from "@mui/icons-material/Clear"; import CheckIcon from "@mui/icons-material/Check"; +import { UsersService } from "@/api/users"; +import cx from "classnames"; + const MatchCard = () => { + const result = UsersService.useGetPotentailUsers(); + const acceptMatch = UsersService.useAcceptMatch(); + const rejectMatch = UsersService.useRejectMatch(); + + // TODO: Add loading spinner or something + if (result.isLoading || acceptMatch.isPending || rejectMatch.isPending) { + return null; + } + + if (result.isError || !result.isSuccess) { + return
Error: {result.error?.message}
; + } + + // TODO: Nicer message when there are no more users + if (result.data.length === 0) { + return
No more users
; + } + + const user = result.data[0]; + return (
-
+
-
+

- Adela, 23 + {`${user.firstName}, ${user.age}`}

-

- Zagreb, Hrvatska -

+

{user.location}

-

- Lorem ipsum, dolor sit amet consectetur adipisicing elit. - Laudantium recusandae iure -

+

{user.description}

- -
diff --git a/frontend/src/components/MyMatches.tsx b/frontend/src/components/MyMatches.tsx new file mode 100644 index 0000000..4cf26ec --- /dev/null +++ b/frontend/src/components/MyMatches.tsx @@ -0,0 +1,47 @@ +import { UsersService } from "@/api/users"; + +const MyMatches = () => { + const query = UsersService.useGetMatches(); + + if (query.isLoading) { + return
Loading...
; + } + + if (query.isError || !query.isSuccess) { + return
Error
; + } + + const matches = query.data; + + return ( +
+
+

+ My matches +

+ {matches.map((match) => ( +
+
+ profile_picture +
+
+

+ {match.firstName} {match.lastName}, {match.age} +

+

{match.location}

+
+
+ ))} +
+
+ ); +}; + +export default MyMatches; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 67f7287..2bb687f 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { RiMatchLogo, CogIcon, HeartIcon, LogoutIcon } from "@/assets"; import useLogout from "@/hooks/useLogout"; import useCurrentUserContext from "@/hooks/useCurrentUser"; +import { Link } from "react-router-dom"; const Navbar: React.FunctionComponent = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -12,57 +13,61 @@ const Navbar: React.FunctionComponent = () => { setIsDropdownOpen(!isDropdownOpen); }; return ( -
-
+ ); }; diff --git a/frontend/src/context/AuthProvider.tsx b/frontend/src/context/AuthProvider.tsx index ce7715f..2745822 100644 --- a/frontend/src/context/AuthProvider.tsx +++ b/frontend/src/context/AuthProvider.tsx @@ -1,26 +1,14 @@ +import { + AuthContextData, + AuthContextProps, + AuthContextType, +} from "@/types/Auth"; import { createContext, useState } from "react"; -interface StateContextType { - auth: T; - setAuth: React.Dispatch>; -} - -interface AuthObject { - user: Record; - accessToken: string; - active: boolean; -} - -type AuthContextType = StateContextType; - const AuthContext = createContext({} as AuthContextType); -interface AuthContextProps { - children: React.ReactNode; -} - export const AuthProvider = ({ children }: AuthContextProps) => { - const [auth, setAuth] = useState(null); + const [auth, setAuth] = useState(null); return ( {children} diff --git a/frontend/src/context/CurrentUserProvider.tsx b/frontend/src/context/CurrentUserProvider.tsx index 264480b..5998ab6 100644 --- a/frontend/src/context/CurrentUserProvider.tsx +++ b/frontend/src/context/CurrentUserProvider.tsx @@ -1,6 +1,6 @@ -import { User, UsersService } from "@/api/users"; +import { UsersService } from "@/api/users"; import { createContext } from "react"; - +import type { User } from "@/types/User"; export const CurrentUserContext = createContext(null); const CurrentUserContextProvider = ({ @@ -10,7 +10,7 @@ const CurrentUserContextProvider = ({ }) => { const currentUserQuery = UsersService.useGetCurrentUser(); if (currentUserQuery.isLoading) { - return

Loading...

; + return null; } if (currentUserQuery.isError) { diff --git a/frontend/src/index.css b/frontend/src/index.css index f2896ee..f47d6df 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -43,7 +43,7 @@ } -html, body { +html, body, #root { height: 100%; margin: 0; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 334c70b..79be35f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,13 +12,21 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import Root from "./views/Root.tsx"; import Preferences from "./views/Preferences.tsx"; +import MyMatches from "./components/MyMatches.tsx"; const router = createBrowserRouter([ { path: "/", element: } />, errorElement: , - children: [{ index: true, element: }], + children: [ + { index: true, element: }, + { + path: "matches", + element: , + errorElement: , + }, + ], }, { path: "/init/preferences", diff --git a/frontend/src/types/Auth.d.ts b/frontend/src/types/Auth.d.ts new file mode 100644 index 0000000..aa7c39a --- /dev/null +++ b/frontend/src/types/Auth.d.ts @@ -0,0 +1,33 @@ +interface StateContextType { + auth: T; + setAuth: React.Dispatch>; +} +export interface AuthContextData { + user: Record; + accessToken: string; + active: boolean; +} +export type AuthContextType = StateContextType; + +export interface AuthContextProps { + children: React.ReactNode; +} + +export interface TokenResponse { + token: string; + active: boolean; +} + +export interface RegisterData { + email: string; + password: string; + firstName: string; + lastName: string; + gender: string; + age: number; +} + +export interface LoginData { + email: string; + password: string; +} diff --git a/frontend/src/types/Match.d.ts b/frontend/src/types/Match.d.ts new file mode 100644 index 0000000..ff5f43f --- /dev/null +++ b/frontend/src/types/Match.d.ts @@ -0,0 +1,10 @@ +export interface Match { + id: string; + firstUserId: string; + secondUserId: string; + accepted: boolean; + finished: boolean; +} +export interface MatchData { + userId: string; +} diff --git a/frontend/src/types/User.d.ts b/frontend/src/types/User.d.ts new file mode 100644 index 0000000..246bc8d --- /dev/null +++ b/frontend/src/types/User.d.ts @@ -0,0 +1,44 @@ +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; + hashedPassword: string; + gender: string; + age: number; + active: boolean; + description: string; + profileImageUrl: string; + phoneNumber: string; + location: string; + preferences: UserPreferences; + lastSeen: string; + createdAt: string; + updatedAt: string; +} + +export type ProjectedUser = Pick< + User, + | "id" + | "firstName" + | "lastName" + | "description" + | "profileImageUrl" + | "location" + | "gender" + | "age" +>; + +interface UserPreferences { + ageGroupMin: number; + ageGroupMax: number; + partnerGender: string; +} + +export interface PreferencesInitData { + description: string; + profileImageUrl: string; + phoneNumber: string; + location: string; + preferences: UserPreferences; +} diff --git a/frontend/src/views/Preferences.tsx b/frontend/src/views/Preferences.tsx index c31b9a7..864bb69 100644 --- a/frontend/src/views/Preferences.tsx +++ b/frontend/src/views/Preferences.tsx @@ -7,7 +7,8 @@ import "aos/dist/aos.css"; import MovmentButtons from "../components/MovmentButtons"; import { useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import { PreferencesInitData, UsersService } from "@/api/users"; +import { UsersService } from "@/api/users"; +import type { PreferencesInitData } from "@/types/User"; import ScrollToFieldError from "@/components/ScrollToFieldError"; const preferenceSchema = Yup.object({