diff --git a/README.md b/README.md index 5b53a0b1..c64badf0 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,34 @@ -# Harbor Take Home Project - -Welcome to the Harbor take home project. We hope this is a good opportunity for you to showcase your skills. - -## The Challenge - -Build us a REST API for calendly. Remember to support - -- Setting own availability -- Showing own availability -- Finding overlap in schedule between 2 users - -It is up to you what else to support. - -## Expectations - -We care about - -- Have you thought through what a good MVP looks like? Does your API support that? -- What trade-offs are you making in your design? -- Working code - we should be able to pull and hit the code locally. Bonus points if deployed somewhere. -- Any good engineer will make hacks when necessary - what are your hacks and why? - -We don't care about - -- Authentication -- UI -- Perfection - good and working quickly is better - -It is up to you how much time you want to spend on this project. There are likely diminishing returns as the time spent goes up. - -## Submission - -Please fork this repository and reach out to Prakash when finished. - -## Next Steps - -After submission, we will conduct a 30 to 60 minute code review in person. We will ask you about your thinking and design choices. +## This is my implementation of calendly with below-mentioned components, features, use cases, assumptions, future scopes, etc. + +## Components +1. This is a spring boot application which is divided into controller(API layer), service(service layer), model(contains object/DTOs), datastore(in-memeroy DB). +2. We have 2 entities i.e. User & Meeting, these entities in turn have 2 dedicated singleton datastores, 2 services and 2 controllers. +3. UserController has user CRUD APIs. +4. MeetingController has scheduleMeeting() API which is used to schedule a meeting between a requestor and a requestee. + +## Features +1. Users can be created, deleted, read and modified(new available slots can be added). +2. Meetings can be scheduled between 2 people. +3. Exception handling is added at all the required places. + +## APIs +1. Create User +2. Delete User +3. Display all users +4. Display a specific user +5. Add available slots to an user +6. Schedule meeting between the requestor and the requestee. + +## Assumptions: +1. A user will always call an API with only his userId as a requestor to schedule a meeting. +2. An in-memory singleton database is used. +3. Requestor will schedule a meet in his own available slot, if not then our pre-validation check throws an exception. +4. By default, all slots are unavailable for a new user. +5. Time format used is [yyyy-MM-dd HH:mm]. + +## Use Case +1. Create user1 +2. Add available slots to user1 e.g. [2024-09-20 10:00 to 2024-09-20 10:30], [2024-09-20 12:00 to 2024-09-20 13:00], [2024-09-20 16:00 to 2024-09-20 16:45] +3. Create user2 +4. Add available slots to user1 e.g. [2024-09-20 10:00 to 2024-09-20 10:30], [2024-09-20 13:00 to 2024-09-20 14:00], [2024-09-20 16:00 to 2024-09-20 16:30] +5. user1 requests to schedule a meeting with user2 at [2024-09-20 10:00 to 2024-09-20 10:30] diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..dad42944 --- /dev/null +++ b/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + org.example + basic-spring-boot + 1.0-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 2.2.5.RELEASE + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-web + + + + com.h2database + h2 + + + + org.projectlombok + lombok + 1.18.12 + provided + + + + org.modelmapper + modelmapper + 2.3.0 + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/src/main/java/com/kaps/Application.java b/src/main/java/com/kaps/Application.java new file mode 100644 index 00000000..c244a5bd --- /dev/null +++ b/src/main/java/com/kaps/Application.java @@ -0,0 +1,11 @@ +package com.kaps; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args){ + SpringApplication.run(Application.class,args); + } +} \ No newline at end of file diff --git a/src/main/java/com/kaps/Utility.java b/src/main/java/com/kaps/Utility.java new file mode 100644 index 00000000..32ae74e3 --- /dev/null +++ b/src/main/java/com/kaps/Utility.java @@ -0,0 +1,10 @@ +package com.kaps; + +import java.util.UUID; + +public class Utility { + + public static final String generateUUID() { + return UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/src/main/java/com/kaps/controller/MeetingController.java b/src/main/java/com/kaps/controller/MeetingController.java new file mode 100644 index 00000000..5e53e14b --- /dev/null +++ b/src/main/java/com/kaps/controller/MeetingController.java @@ -0,0 +1,25 @@ +package com.kaps.controller; + +import com.kaps.model.Meeting; +import com.kaps.service.MeetingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@Controller +@RequestMapping("/meeting") +public class MeetingController { + @Autowired MeetingService meetingService; + + @ResponseBody + @PostMapping("schedule") + public Meeting scheduleMeeting(@RequestParam("requestor") String requestor, + @RequestParam("requestee") String requestee, + @RequestParam("startTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime startTime, + @RequestParam("endTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime endTime) throws Exception { + return meetingService.scheduleMeeting(requestor, requestee, startTime, endTime); + } +} diff --git a/src/main/java/com/kaps/controller/UserController.java b/src/main/java/com/kaps/controller/UserController.java new file mode 100644 index 00000000..f965615b --- /dev/null +++ b/src/main/java/com/kaps/controller/UserController.java @@ -0,0 +1,49 @@ +package com.kaps.controller; + +import com.kaps.model.User; +import com.kaps.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Controller +@RequestMapping("/user") +public class UserController { + @Autowired UserService userService; + + @ResponseBody + @PostMapping + public User createUser(@RequestBody User user) throws Exception { + return userService.createUser(user); + } + + @ResponseBody + @GetMapping("/allusers") + public List getUsers() throws Exception { + return userService.getUsers(); + } + + @ResponseBody + @GetMapping + public User getUser(@RequestParam("userName") String userName) throws Exception { + return userService.getUserByName(userName); + } + + @ResponseBody + @DeleteMapping + public void deleteUser(@RequestParam("userName") String userName) throws Exception { + userService.deleteUser(userName); + } + + @ResponseBody + @PutMapping + public User addAvailableSlot(@RequestParam("userName") String userName, + @RequestParam("startTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime startTime, + @RequestParam("endTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime endTime) throws Exception { + return userService.addAvailableSlot(userName, startTime, endTime); + } +} diff --git a/src/main/java/com/kaps/datastore/MeetingDataStore.java b/src/main/java/com/kaps/datastore/MeetingDataStore.java new file mode 100644 index 00000000..12fdf58d --- /dev/null +++ b/src/main/java/com/kaps/datastore/MeetingDataStore.java @@ -0,0 +1,26 @@ +package com.kaps.datastore; + +import com.kaps.Utility; +import com.kaps.model.Meeting; + +import java.util.HashMap; +import java.util.Map; + +public class MeetingDataStore { + Map meetingDataMap; + + public static MeetingDataStore getInstance() { + return new MeetingDataStore(); + } + + private MeetingDataStore() { + meetingDataMap = new HashMap<>(); + } + + public Meeting scheduleMeeting(Meeting meeting) { + meeting.setId(Utility.generateUUID()); + meetingDataMap.put(meeting.getId(), meeting); + + return meetingDataMap.get(meeting.getId()); + } +} diff --git a/src/main/java/com/kaps/datastore/UserDataStore.java b/src/main/java/com/kaps/datastore/UserDataStore.java new file mode 100644 index 00000000..c8304fac --- /dev/null +++ b/src/main/java/com/kaps/datastore/UserDataStore.java @@ -0,0 +1,99 @@ +package com.kaps.datastore; + +import com.kaps.Utility; +import com.kaps.model.Slot; +import com.kaps.model.User; + +import java.time.LocalDateTime; +import java.util.*; + +public class UserDataStore { + Map userNameToUserMap; + + public static UserDataStore getInstance() { + return new UserDataStore(); + } + + private UserDataStore() { + userNameToUserMap = new HashMap<>(); + } + + public User createUser(User user) throws Exception { + checkIfUserAlreadyExists(user.getName()); + user.setId(Utility.generateUUID()); + userNameToUserMap.put(user.getName(), user); + + return userNameToUserMap.get(user.getName()); + } + + public void deleteUser(String userName) throws Exception { + checkIfUserExists(userName); + userNameToUserMap.remove(userName); + } + + public User addAvailableSlot(String userName, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + checkIfUserExists(userName); + performSanity(startTime, endTime); + + User user = userNameToUserMap.get(userName); + user.getAvailableSlots().add( + new Slot(UUID.randomUUID().toString().replace("-", ""), startTime, endTime)); + + return userNameToUserMap.get(userName); + } + + public List getUsers() { + List users = new ArrayList<>(); + + for (String userName: userNameToUserMap.keySet()) { + users.add(userNameToUserMap.get(userName)); + } + + return users; + } + + public User getUserByName(String userName) throws Exception { + checkIfUserExists(userName); + + return userNameToUserMap.get(userName); + } + + public List getAvailableSlots(String userName) throws Exception { + checkIfUserExists(userName); + + return new ArrayList<>(userNameToUserMap.get(userName).getAvailableSlots()); + } + + public void removeSlot(String userName, String slotId) throws Exception { + Set slots = userNameToUserMap.get(userName).getAvailableSlots(); + Set newSlots = new HashSet<>(); + + for (Slot slot: slots) { + if (!slot.getId().equals(slotId)) + newSlots.add(slot); + } + + userNameToUserMap.get(userName).setAvailableSlots(newSlots); + } + + private void checkIfUserExists(String userName) throws Exception { + if (!userNameToUserMap.containsKey(userName)) { + throw new Exception("No user exists with userName: " + userName); + } + } + + private void checkIfUserAlreadyExists(String userName) throws Exception { + if (userNameToUserMap.containsKey(userName)) { + throw new Exception("User already exists with userName: " + userName); + } + } + + public void performSanity(LocalDateTime startTime, LocalDateTime endTime) throws Exception { + if (startTime.isAfter(endTime)) { + throw new Exception("Meeting start time cannot be after meeting end time!"); + } + if (startTime.isBefore(LocalDateTime.now())) { + throw new Exception("Cannot schedule an expired meeting!"); + } + } +} diff --git a/src/main/java/com/kaps/model/Meeting.java b/src/main/java/com/kaps/model/Meeting.java new file mode 100644 index 00000000..91475488 --- /dev/null +++ b/src/main/java/com/kaps/model/Meeting.java @@ -0,0 +1,23 @@ +package com.kaps.model; + +import javafx.util.Pair; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.Id; + +import java.time.LocalDateTime; +import java.util.Set; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Meeting { + @Id private String id; + private String requestor; + private String requestee; + private LocalDateTime startTime; + private LocalDateTime endTime; +} diff --git a/src/main/java/com/kaps/model/Slot.java b/src/main/java/com/kaps/model/Slot.java new file mode 100644 index 00000000..72d08402 --- /dev/null +++ b/src/main/java/com/kaps/model/Slot.java @@ -0,0 +1,19 @@ +package com.kaps.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.Id; + +import java.time.LocalDateTime; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Slot { + @Id private String id; + private LocalDateTime startTime; + private LocalDateTime endTime; +} diff --git a/src/main/java/com/kaps/model/User.java b/src/main/java/com/kaps/model/User.java new file mode 100644 index 00000000..029db72b --- /dev/null +++ b/src/main/java/com/kaps/model/User.java @@ -0,0 +1,20 @@ +package com.kaps.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.Id; + +import java.util.Set; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class User { + @Id private String id; + private String name; + private String email; + private Set availableSlots; +} diff --git a/src/main/java/com/kaps/service/MeetingService.java b/src/main/java/com/kaps/service/MeetingService.java new file mode 100644 index 00000000..7e06e27b --- /dev/null +++ b/src/main/java/com/kaps/service/MeetingService.java @@ -0,0 +1,50 @@ +package com.kaps.service; + +import com.kaps.datastore.MeetingDataStore; +import com.kaps.model.Meeting; +import com.kaps.model.Slot; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class MeetingService { + MeetingDataStore meetingDataStore; + @Autowired UserService userService; + + public MeetingService() { + meetingDataStore = MeetingDataStore.getInstance(); + userService = new UserService(); + } + + /* + * Scheduling Algo: + * 1. the requestor should have exact slot present otherwise throw exception, capture this slot id + * 2. then find overlapping slot for requestee + * 3. schedule the meet + * 4. remove these slots + * */ + + public Meeting scheduleMeeting(String requestor, String requestee, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + performMeetingSanity(startTime, endTime); + if (!userService.isSlotAvailable(requestor, startTime, endTime)) { + throw new Exception("Requestor of the meeting does not have the slot himself. Kindly add a slot and then schedule!"); + } + Slot requesteeSlot = userService.getOverlappingSlots(requestee, startTime, endTime); + Meeting meet = meetingDataStore.scheduleMeeting(new Meeting(null, requestor, requestee, startTime, endTime)); + userService.removeSlot(requestee, requesteeSlot.getId()); + userService.removeSlot(requestor, userService.getSlotId(requestor, startTime, endTime)); + + return meet; + } + + public void performMeetingSanity(LocalDateTime startTime, LocalDateTime endTime) throws Exception { + if (startTime.isAfter(endTime)) { + throw new Exception("Meeting start time cannot be after meeting end time!"); + } + if (startTime.isBefore(LocalDateTime.now())) { + throw new Exception("Cannot schedule an expired meeting!"); + } + } +} diff --git a/src/main/java/com/kaps/service/UserService.java b/src/main/java/com/kaps/service/UserService.java new file mode 100644 index 00000000..0aa01343 --- /dev/null +++ b/src/main/java/com/kaps/service/UserService.java @@ -0,0 +1,75 @@ +package com.kaps.service; + +import com.kaps.datastore.UserDataStore; +import com.kaps.model.Slot; +import com.kaps.model.User; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class UserService { + UserDataStore userDataStore; + + public UserService() { + userDataStore = UserDataStore.getInstance(); + } + + public User createUser(User user) throws Exception { + return userDataStore.createUser(user); + } + + public void deleteUser(String userName) throws Exception { + userDataStore.deleteUser(userName); + } + + public User addAvailableSlot(String userName, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + return userDataStore.addAvailableSlot(userName, startTime, endTime); + } + + public List getUsers() { + return userDataStore.getUsers(); + } + + public User getUserByName(String userName) throws Exception { + return userDataStore.getUserByName(userName); + } + + public boolean isSlotAvailable(String userName, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + for (Slot slot: userDataStore.getAvailableSlots(userName)) { + if (slot.getStartTime().equals(startTime) && slot.getEndTime().equals(endTime)) + return true; + } + + return false; + } + + public String getSlotId(String userName, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + for (Slot slot: userDataStore.getAvailableSlots(userName)) { + if (slot.getStartTime().equals(startTime) && slot.getEndTime().equals(endTime)) + return slot.getId(); + } + + return ""; + } + + public void removeSlot(String userName, String slotId) throws Exception { + userDataStore.removeSlot(userName, slotId); + } + + public Slot getOverlappingSlots(String userName, LocalDateTime startTime, LocalDateTime endTime) throws Exception { + List slots = userDataStore.getAvailableSlots(userName); + + for (Slot slot: slots) { + + + if (slot.getStartTime().equals(startTime) && slot.getEndTime().equals(endTime)) + return slot; + else if (slot.getStartTime().isBefore(startTime) && (slot.getEndTime().equals(endTime) || slot.getEndTime().isAfter(endTime))) + return slot; + } + + return new Slot(); + } +}