From d44934b8c7fcc0fc207e90f7dd3ecc53d1c2bcd3 Mon Sep 17 00:00:00 2001 From: DimaRus05 Date: Fri, 13 Jun 2025 16:29:39 +0300 Subject: [PATCH 1/5] a bit improved AI-generation --- src/main/java/com/smartcalendar/service/ChatGPTService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 6926e17..f700075 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -88,6 +88,8 @@ public Map> generateEventsAndTasks(String userQuery) { logger.info("Generating events and tasks for query: {}", userQuery); String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events and tasks. " + + "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + + "unless it is clearly a separate task. " + "Respond strictly in JSON format with the following structure: " + "{ \"events\": [{ " + "\"title\": \"string\", " + @@ -191,6 +193,8 @@ public Map processTranscript(String transcript) { "\"dueDateTime\": \"ISO 8601 datetime\", " + "\"allDay\": false " + "}] } " + + "If the transcript contains a note, description, or additional information about an event, include it in the 'description' field of the event, " + + "unless it is clearly a separate task. " + "If the transcript is not related to events or tasks, respond with: { \"error\": \"Unrelated request\" }. " + "Do not include any additional text or explanation."; From 6a698e054c3e585a346d6cc972d83cb7e00b0ec3 Mon Sep 17 00:00:00 2001 From: DimaRus05 Date: Fri, 13 Jun 2025 17:41:32 +0300 Subject: [PATCH 2/5] collaborative events implementation attempt --- build.gradle | 1 + .../controller/UserController.java | 80 +++++++++++++++++++ .../dto/AddCollaborativeEventRequest.java | 10 +++ .../java/com/smartcalendar/model/Event.java | 10 +++ .../service/NotificationService.java | 27 +++++++ .../service/PushNotificationService.java | 37 +++++++++ .../smartcalendar/service/UserService.java | 34 ++++++++ src/main/resources/application.properties | 15 +++- 8 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java create mode 100644 src/main/java/com/smartcalendar/service/NotificationService.java create mode 100644 src/main/java/com/smartcalendar/service/PushNotificationService.java diff --git a/build.gradle b/build.gradle index cd2eb5c..4aaf76c 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index 0b014d5..25763f7 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -1,5 +1,6 @@ package com.smartcalendar.controller; +import com.smartcalendar.dto.AddCollaborativeEventRequest; import com.smartcalendar.dto.DailyTaskDto; import com.smartcalendar.dto.StatisticsData; import com.smartcalendar.model.Event; @@ -14,6 +15,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; @RestController @@ -276,4 +278,82 @@ public ResponseEntity updateStatistics( userService.updateStatistics(userId, statisticsData); return ResponseEntity.ok().build(); } + + @PostMapping("/events/collaborative") + public ResponseEntity addCollaborativeEvent( + @RequestBody AddCollaborativeEventRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + + Optional participantOpt = userService.findByUsername(request.getLoginOrEmail()); + if (participantOpt.isEmpty()) { + participantOpt = userService.findByEmail(request.getLoginOrEmail()); + } + if (participantOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User participant = participantOpt.get(); + + Event event = request.getEvent(); + event.setOrganizer(currentUser); + event.setId(UUID.randomUUID()); + event.setParticipants(List.of(currentUser, participant)); + Event savedEvent = userService.createEventWithCustomId(event); + + userService.notifyUserAddedToEvent(participant, event, request.getDeviceToken()); + + return ResponseEntity.ok(savedEvent); + } + + @PostMapping("/events/{eventId}/remove-participant") + public ResponseEntity removeParticipant( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + Event event = userService.getEventById(eventId); + String loginOrEmail = requestBody.get("loginOrEmail"); + String deviceToken = requestBody.get("deviceToken"); + + Optional userOpt = userService.findByUsername(loginOrEmail); + if (userOpt.isEmpty()) { + userOpt = userService.findByEmail(loginOrEmail); + } + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + event.getParticipants().remove(user); + userService.notifyUserRemovedFromEvent(user, event, deviceToken); + userService.createEventWithCustomId(event); + return ResponseEntity.ok(Map.of("removed", user.getUsername())); + } + + @PostMapping("/events/{eventId}/add-participant") + public ResponseEntity addParticipantToEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + Event event = userService.getEventById(eventId); + + String loginOrEmail = requestBody.get("loginOrEmail"); + String deviceToken = requestBody.get("deviceToken"); + + Optional userOpt = userService.findByUsername(loginOrEmail); + if (userOpt.isEmpty()) { + userOpt = userService.findByEmail(loginOrEmail); + } + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + + if (!event.getParticipants().contains(user)) { + event.getParticipants().add(user); + userService.createEventWithCustomId(event); + userService.notifyUserAddedToEvent(user, event, deviceToken); + } + + return ResponseEntity.ok(Map.of("added", user.getUsername())); + } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java b/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java new file mode 100644 index 0000000..d093bbc --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java @@ -0,0 +1,10 @@ +package com.smartcalendar.dto; +import com.smartcalendar.model.Event; +import lombok.Data; + +@Data +public class AddCollaborativeEventRequest { + private String loginOrEmail; + private String deviceToken; + private Event event; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Event.java b/src/main/java/com/smartcalendar/model/Event.java index bee786a..44751a8 100644 --- a/src/main/java/com/smartcalendar/model/Event.java +++ b/src/main/java/com/smartcalendar/model/Event.java @@ -6,6 +6,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @Entity @@ -40,4 +42,12 @@ public class Event { private User organizer; private boolean completed = false; + + @ManyToMany + @JoinTable( + name = "event_participants", + joinColumns = @JoinColumn(name = "event_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private List participants = new ArrayList<>(); } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java new file mode 100644 index 0000000..f40de48 --- /dev/null +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -0,0 +1,27 @@ +package com.smartcalendar.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final JavaMailSender mailSender; + private final PushNotificationService pushNotificationService; + + public void sendEmail(String to, String subject, String text) { + //if (to == null || to.isBlank()) return; + //SimpleMailMessage message = new SimpleMailMessage(); + //message.setTo(to); + //message.setSubject(subject); + //message.setText(text); + //mailSender.send(message); + }// + + public void sendPush(String deviceToken, String title, String body) { + if (deviceToken == null || deviceToken.isBlank()) return; + pushNotificationService.sendPush(deviceToken, title, body); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/PushNotificationService.java b/src/main/java/com/smartcalendar/service/PushNotificationService.java new file mode 100644 index 0000000..6bb45ff --- /dev/null +++ b/src/main/java/com/smartcalendar/service/PushNotificationService.java @@ -0,0 +1,37 @@ +package com.smartcalendar.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class PushNotificationService { + + @Value("${fcm.server.key}") + private String fcmServerKey; + + private static final String FCM_API_URL = "https://fcm.googleapis.com/fcm/send"; + + public void sendPush(String deviceToken, String title, String body) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "key=" + fcmServerKey); + + Map notification = Map.of( + "title", title, + "body", body + ); + Map message = Map.of( + "to", deviceToken, + "notification", notification + ); + + HttpEntity> request = new HttpEntity<>(message, headers); + restTemplate.postForEntity(FCM_API_URL, request, String.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 8aa3b65..b7ed9ce 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -32,6 +32,8 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final StatisticsService statisticsService; private final StatisticsRepository statisticsRepository; + private final NotificationService notificationService; + private final PushNotificationService pushNotificationService; public User createUser(User user) { if (userRepository.existsByUsername(user.getUsername())) { @@ -276,4 +278,36 @@ public void deleteUser(Long userId) { public Optional findByEmail(String email) { return userRepository.findByEmail(email); } + + public void notifyUserAddedToEvent(User user, Event event, String deviceToken) { + if (user.getEmail() != null && !user.getEmail().isBlank()) { + String subject = "You have been added to event: " + event.getTitle(); + String text = "Hello, you have been added to the event \"" + event.getTitle() + "\" by " + + (event.getOrganizer() != null ? event.getOrganizer().getUsername() : "system") + "."; + notificationService.sendEmail(user.getEmail(), subject, text); + } + if (deviceToken != null && !deviceToken.isBlank()) { + notificationService.sendPush( + deviceToken, + "Added to event", + "You have been added to event: " + event.getTitle() + ); + } + } + + public void notifyUserRemovedFromEvent(User user, Event event, String deviceToken) { + sendEventEmail(user, event, "You have been removed from event: ", "removed from the event"); + notificationService.sendPush(deviceToken, + "Removed from event", + "You have been removed from event: " + event.getTitle()); + } + + private void sendEventEmail(User user, Event event, String subjectPrefix, String actionText) { + if (user.getEmail() != null && !user.getEmail().isBlank()) { + String subject = subjectPrefix + event.getTitle(); + String text = "Hello, you have been " + actionText + " \"" + event.getTitle() + "\"" + + (event.getOrganizer() != null ? " by " + event.getOrganizer().getUsername() : "") + "."; + notificationService.sendEmail(user.getEmail(), subject, text); + } + } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 42b6d97..c7a9a29 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,4 +56,17 @@ spring.main.banner-mode=off chatgpt.api.url=https://api.openai.com/v1/chat/completions whisper.api.url=https://api.openai.com/v1/audio/transcriptions -chatgpt.api.key=${CHATGPT_API_KEY} \ No newline at end of file +chatgpt.api.key=${CHATGPT_API_KEY} + +# =============================== +# SMTP +# =============================== +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=dimarus06122005@gmail.com +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.from=dimarus06122005@gmail.com + +fcm.server.key=${FCM_KEY} \ No newline at end of file From 1b4232e627705df9d6a9ecd93a6419e9ef337fae Mon Sep 17 00:00:00 2001 From: DimaRus05 Date: Sat, 14 Jun 2025 20:09:03 +0300 Subject: [PATCH 3/5] basic logic added for collaborative events --- .gitignore | 3 + build.gradle | 2 + .../controller/AuthController.java | 10 ++ .../controller/UserController.java | 147 ++++++++++++------ .../java/com/smartcalendar/model/Event.java | 7 + .../java/com/smartcalendar/model/User.java | 3 + .../smartcalendar/service/ChatGPTService.java | 3 + .../service/NotificationService.java | 18 +-- .../service/PushNotificationService.java | 62 +++++--- .../smartcalendar/service/UserService.java | 86 +++++++++- src/main/resources/application.properties | 3 +- 11 files changed, 256 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 63d2f1a..ca2bfc8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ out/ ### VS Code ### .vscode/ + +### Firebase Service Account ### +timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json diff --git a/build.gradle b/build.gradle index 4aaf76c..c8ca27e 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.19.0' + implementation 'com.google.api-client:google-api-client:2.2.0' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/smartcalendar/controller/AuthController.java b/src/main/java/com/smartcalendar/controller/AuthController.java index a4fcf7a..a1717fc 100644 --- a/src/main/java/com/smartcalendar/controller/AuthController.java +++ b/src/main/java/com/smartcalendar/controller/AuthController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.util.Optional; @RestController @RequestMapping("/api/auth") @@ -79,6 +80,15 @@ public ResponseEntity authenticateUser(@RequestBody User user) { logger.debug("Authentication successful for user: {}", userDetails.getUsername()); SecurityContextHolder.getContext().setAuthentication(authentication); + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + Optional dbUserOpt = userService.findByUsername(userDetails.getUsername()); + if (dbUserOpt.isPresent()) { + User dbUser = dbUserOpt.get(); + dbUser.setDeviceToken(user.getDeviceToken()); + userService.createUser(dbUser); + } + } + String jwt = jwtService.generateToken(userDetails.getUsername()); logger.info("JWT token generated for user: {}", userDetails.getUsername()); diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index 25763f7..5e39cf2 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -13,10 +13,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; @RestController @RequestMapping("/api/users") @@ -129,6 +126,10 @@ public ResponseEntity> createEvent( return ResponseEntity.status(403).build(); } event.setOrganizer(currentUser); + event.setShared(false); + event.setInvitees(new ArrayList<>()); + event.setParticipants(List.of(currentUser)); + try { Event createdEvent = userService.createEventWithCustomId(event); return ResponseEntity.ok(Map.of("id", createdEvent.getId())); @@ -148,7 +149,11 @@ public ResponseEntity updateEvent( if (!existingEvent.getOrganizer().getId().equals(currentUser.getId())) { return ResponseEntity.status(403).build(); } + userService.editEvent(eventId, event); + + userService.notifyEventUpdated(existingEvent, event); + return ResponseEntity.ok().build(); } @@ -235,6 +240,9 @@ public ResponseEntity updateEventStatus( } boolean completed = requestBody.get("completed"); Event updatedEvent = userService.updateEventStatus(eventId, completed); + + userService.notifyEventUpdated(event, updatedEvent); + return ResponseEntity.ok(updatedEvent); } @@ -242,12 +250,16 @@ public ResponseEntity updateEventStatus( public ResponseEntity> deleteEventById( @PathVariable UUID eventId, @AuthenticationPrincipal UserDetails userDetails) { - Event event = userService.getEventById(eventId); User currentUser = userService.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + if (!event.getOrganizer().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); + return ResponseEntity.status(403).body(Map.of()); } + + userService.notifyEventDeleted(event); + UUID deletedId = userService.deleteEventById(eventId); return ResponseEntity.ok(Map.of("id", deletedId)); } @@ -279,81 +291,118 @@ public ResponseEntity updateStatistics( return ResponseEntity.ok().build(); } - @PostMapping("/events/collaborative") - public ResponseEntity addCollaborativeEvent( - @RequestBody AddCollaborativeEventRequest request, + @PostMapping("/events/{eventId}/invite") + public ResponseEntity inviteUserToEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); + String loginOrEmail = requestBody.get("loginOrEmail"); + Event event = userService.getEventById(eventId); - Optional participantOpt = userService.findByUsername(request.getLoginOrEmail()); - if (participantOpt.isEmpty()) { - participantOpt = userService.findByEmail(request.getLoginOrEmail()); - } - if (participantOpt.isEmpty()) { + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); + if (userOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "User not found")); } - User participant = participantOpt.get(); + User user = userOpt.get(); - Event event = request.getEvent(); - event.setOrganizer(currentUser); - event.setId(UUID.randomUUID()); - event.setParticipants(List.of(currentUser, participant)); - Event savedEvent = userService.createEventWithCustomId(event); + if (event.getParticipants() != null && event.getParticipants().contains(user)) { + return ResponseEntity.badRequest().body(Map.of("error", "User is already a participant")); + } + if (event.getInvitees() != null && event.getInvitees().contains(user.getEmail())) { + return ResponseEntity.badRequest().body(Map.of("error", "User is already invited")); + } + if (event.getInvitees() == null) { + event.setInvitees(new ArrayList<>()); + } + event.getInvitees().add(user.getEmail()); + event.setShared(true); - userService.notifyUserAddedToEvent(participant, event, request.getDeviceToken()); + userService.saveEvent(event); - return ResponseEntity.ok(savedEvent); + return ResponseEntity.ok(Map.of("invited", user.getUsername())); } - @PostMapping("/events/{eventId}/remove-participant") - public ResponseEntity removeParticipant( + @PostMapping("/events/{eventId}/remove-invite") + public ResponseEntity removeInviteFromEvent( @PathVariable UUID eventId, @RequestBody Map requestBody, @AuthenticationPrincipal UserDetails userDetails) { - Event event = userService.getEventById(eventId); String loginOrEmail = requestBody.get("loginOrEmail"); - String deviceToken = requestBody.get("deviceToken"); + Event event = userService.getEventById(eventId); - Optional userOpt = userService.findByUsername(loginOrEmail); - if (userOpt.isEmpty()) { - userOpt = userService.findByEmail(loginOrEmail); - } + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); if (userOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "User not found")); } User user = userOpt.get(); - event.getParticipants().remove(user); - userService.notifyUserRemovedFromEvent(user, event, deviceToken); - userService.createEventWithCustomId(event); - return ResponseEntity.ok(Map.of("removed", user.getUsername())); + + if (event.getInvitees() != null) { + event.getInvitees().remove(user.getEmail()); + userService.saveEvent(event); + } + + return ResponseEntity.ok(Map.of("removedInvite", user.getUsername())); + } + + @GetMapping("/me/invites") + public ResponseEntity> getMyInvites(@AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + List invites = userService.findEventsByInvitee(currentUser.getEmail()); + return ResponseEntity.ok(invites); } - @PostMapping("/events/{eventId}/add-participant") - public ResponseEntity addParticipantToEvent( + @PostMapping("/events/{eventId}/accept-invite") + public ResponseEntity acceptInvite( @PathVariable UUID eventId, - @RequestBody Map requestBody, @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); Event event = userService.getEventById(eventId); - String loginOrEmail = requestBody.get("loginOrEmail"); - String deviceToken = requestBody.get("deviceToken"); + if (event.getInvitees() == null || !event.getInvitees().contains(currentUser.getEmail())) { + return ResponseEntity.badRequest().body(Map.of("error", "No invite found for this user")); + } - Optional userOpt = userService.findByUsername(loginOrEmail); - if (userOpt.isEmpty()) { - userOpt = userService.findByEmail(loginOrEmail); + event.getInvitees().remove(currentUser.getEmail()); + if (!event.getParticipants().contains(currentUser)) { + event.getParticipants().add(currentUser); } + userService.saveEvent(event); + + return ResponseEntity.ok(Map.of("accepted", true)); + } + + @PostMapping("/events/{eventId}/remove-participant") + public ResponseEntity removeParticipantFromEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + + if (!event.getOrganizer().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).body(Map.of("error", "Only organizer can remove participants")); + } + + String loginOrEmail = requestBody.get("loginOrEmail"); + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); if (userOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "User not found")); } User user = userOpt.get(); - if (!event.getParticipants().contains(user)) { - event.getParticipants().add(user); - userService.createEventWithCustomId(event); - userService.notifyUserAddedToEvent(user, event, deviceToken); + if (user.getId().equals(currentUser.getId())) { + return ResponseEntity.badRequest().body(Map.of("error", "Organizer cannot be removed")); } - return ResponseEntity.ok(Map.of("added", user.getUsername())); + boolean removed = event.getParticipants() != null && event.getParticipants().remove(user); + if (removed) { + userService.saveEvent(event); + return ResponseEntity.ok(Map.of("removedParticipant", user.getUsername())); + } else { + return ResponseEntity.badRequest().body(Map.of("error", "User is not a participant")); + } } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Event.java b/src/main/java/com/smartcalendar/model/Event.java index 44751a8..b7c7b46 100644 --- a/src/main/java/com/smartcalendar/model/Event.java +++ b/src/main/java/com/smartcalendar/model/Event.java @@ -43,6 +43,13 @@ public class Event { private boolean completed = false; + private boolean isShared = false; + + @ElementCollection + @CollectionTable(name = "event_invitees", joinColumns = @JoinColumn(name = "event_id")) + @Column(name = "invitee") + private List invitees = new ArrayList<>(); + @ManyToMany @JoinTable( name = "event_participants", diff --git a/src/main/java/com/smartcalendar/model/User.java b/src/main/java/com/smartcalendar/model/User.java index c6cd1d7..89484a5 100644 --- a/src/main/java/com/smartcalendar/model/User.java +++ b/src/main/java/com/smartcalendar/model/User.java @@ -37,6 +37,9 @@ public class User implements UserDetails { @JsonManagedReference private List tasks; + @Column(name = "device_token") + private String deviceToken; + @Override public Collection getAuthorities() { return Collections.emptyList(); diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index f700075..1a7ae3b 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -144,6 +144,9 @@ public List convertToEntities(Map> data) { if (!event.isCompleted() && eventData.get("completed") != null) { event.setCompleted(Boolean.parseBoolean(eventData.get("completed").toString())); } + event.setShared(false); + event.setInvitees(new ArrayList<>()); + event.setParticipants(new ArrayList<>()); entities.add(event); } } diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java index f40de48..9ec5f85 100644 --- a/src/main/java/com/smartcalendar/service/NotificationService.java +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -1,27 +1,19 @@ package com.smartcalendar.service; import lombok.RequiredArgsConstructor; -import org.springframework.mail.SimpleMailMessage; -import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class NotificationService { - private final JavaMailSender mailSender; - private final PushNotificationService pushNotificationService; + // private final JavaMailSender mailSender; + // private final PushNotificationService pushNotificationService; public void sendEmail(String to, String subject, String text) { - //if (to == null || to.isBlank()) return; - //SimpleMailMessage message = new SimpleMailMessage(); - //message.setTo(to); - //message.setSubject(subject); - //message.setText(text); - //mailSender.send(message); - }// + System.out.println("[STUB] Email to: " + to + ", subject: " + subject + ", text: " + text); + } public void sendPush(String deviceToken, String title, String body) { - if (deviceToken == null || deviceToken.isBlank()) return; - pushNotificationService.sendPush(deviceToken, title, body); + System.out.println("[STUB] Push to: " + deviceToken + ", title: " + title + ", body: " + body); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/PushNotificationService.java b/src/main/java/com/smartcalendar/service/PushNotificationService.java index 6bb45ff..683b957 100644 --- a/src/main/java/com/smartcalendar/service/PushNotificationService.java +++ b/src/main/java/com/smartcalendar/service/PushNotificationService.java @@ -1,37 +1,59 @@ package com.smartcalendar.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; +import org.springframework.util.ResourceUtils; import org.springframework.web.client.RestTemplate; +import java.io.FileInputStream; +import java.util.List; import java.util.Map; @Service public class PushNotificationService { - @Value("${fcm.server.key}") - private String fcmServerKey; + @Value("${firebase.project-id}") + private String projectId; - private static final String FCM_API_URL = "https://fcm.googleapis.com/fcm/send"; + @Value("${firebase.credentials.path}") + private String credentialsPath; + + private static final ObjectMapper objectMapper = new ObjectMapper(); public void sendPush(String deviceToken, String title, String body) { - RestTemplate restTemplate = new RestTemplate(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "key=" + fcmServerKey); - - Map notification = Map.of( - "title", title, - "body", body - ); - Map message = Map.of( - "to", deviceToken, - "notification", notification - ); - - HttpEntity> request = new HttpEntity<>(message, headers); - restTemplate.postForEntity(FCM_API_URL, request, String.class); + try { + String url = "https://fcm.googleapis.com/v1/projects/" + projectId + "/messages:send"; + + GoogleCredentials credentials = GoogleCredentials + .fromStream(new FileInputStream(ResourceUtils.getFile(credentialsPath))) + .createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging")); + credentials.refreshIfExpired(); + String accessToken = credentials.getAccessToken().getTokenValue(); + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map notification = Map.of( + "title", title, + "body", body + ); + Map message = Map.of( + "token", deviceToken, + "notification", notification + ); + Map bodyMap = Map.of("message", message); + + String jsonBody = objectMapper.writeValueAsString(bodyMap); + + HttpEntity request = new HttpEntity<>(jsonBody, headers); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.postForEntity(url, request, String.class); + } catch (Exception e) { + e.printStackTrace(); + } } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index b7ed9ce..0bcd4ea 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -9,6 +9,7 @@ import com.smartcalendar.repository.StatisticsRepository; import com.smartcalendar.repository.TaskRepository; import com.smartcalendar.repository.UserRepository; +import jakarta.validation.constraints.Email; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -17,10 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Service @@ -160,7 +158,13 @@ public String getTaskDescription(UUID taskId) { } public List findEventsByUserId(Long userId) { - return eventRepository.findByOrganizerId(userId); + List asOrganizer = eventRepository.findByOrganizerId(userId); + List asParticipant = eventRepository.findAll().stream() + .filter(e -> e.getParticipants() != null && e.getParticipants().stream().anyMatch(u -> u.getId().equals(userId))) + .collect(Collectors.toList()); + Set all = new HashSet<>(asOrganizer); + all.addAll(asParticipant); + return new ArrayList<>(all); } public Event createEvent(Event event) { @@ -310,4 +314,76 @@ private void sendEventEmail(User user, Event event, String subjectPrefix, String notificationService.sendEmail(user.getEmail(), subject, text); } } + + public List findEventsByInvitee(String email) { + return eventRepository.findAll().stream() + .filter(event -> event.getInvitees() != null && event.getInvitees().contains(email)) + .collect(Collectors.toList()); + } + + + public void notifyInvitees(Event event) { + if (event.getInvitees() == null || event.getInvitees().isEmpty()) return; + + String subject = "You have been invited to the event: " + event.getTitle(); + String text = "You have been invited by " + + (event.getOrganizer() != null ? event.getOrganizer().getUsername() : "a user") + + " to the event \"" + event.getTitle() + "\"."; + + for (String email : event.getInvitees()) { + Optional userOpt = findByEmail(email); + if (userOpt.isPresent()) { + User user = userOpt.get(); + notificationService.sendEmail(user.getEmail(), subject, text); + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + notificationService.sendPush(user.getDeviceToken(), "Invitation", text); + } + } else { + notificationService.sendEmail(email, subject, text); + } + } + } + + public Optional findByLoginOrEmail(String loginOrEmail) { + Optional userOpt = userRepository.findByUsername(loginOrEmail); + if (userOpt.isEmpty()) { + userOpt = userRepository.findByEmail(loginOrEmail); + } + return userOpt; + } + + @Transactional + public Event saveEvent(Event event) { + return eventRepository.save(event); + } + + public void notifyEventDeleted(Event event) { + String subject = "Event \"" + event.getTitle() + "\" has been deleted"; + String text = "The organizer " + event.getOrganizer().getUsername() + " has deleted the event."; + notifyAllEventUsers(event, subject, text); + } + + public void notifyEventUpdated(Event oldEvent, Event newEvent) { + String subject = "Event \"" + oldEvent.getTitle() + "\" has been updated"; + String text = "The organizer " + oldEvent.getOrganizer().getUsername() + " has updated the event details."; + notifyAllEventUsers(oldEvent, subject, text); + } + + private void notifyAllEventUsers(Event event, String subject, String text) { + if (event.getParticipants() != null) { + for (User user : event.getParticipants()) { + if (!user.getId().equals(event.getOrganizer().getId())) { + notificationService.sendEmail(user.getEmail(), subject, text); + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + notificationService.sendPush(user.getDeviceToken(), subject, text); + } + } + } + } + if (event.getInvitees() != null) { + for (String email : event.getInvitees()) { + notificationService.sendEmail(email, subject, text); + } + } + } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c7a9a29..6d7a8fc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -69,4 +69,5 @@ spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.from=dimarus06122005@gmail.com -fcm.server.key=${FCM_KEY} \ No newline at end of file +firebase.project-id=timetamer-smarcalendar +firebase.credentials.path=classpath:firebase-service-account.json \ No newline at end of file From 65debe38f335518e0e00069e3eb3a44e7dc8b2bb Mon Sep 17 00:00:00 2001 From: DimaRus05 Date: Sat, 14 Jun 2025 21:01:16 +0300 Subject: [PATCH 4/5] privacy fix --- .../controller/UserController.java | 21 ++++++++++---- .../java/com/smartcalendar/dto/EventDto.java | 25 ++++++++++++++++ .../com/smartcalendar/dto/UserShortDto.java | 11 +++++++ .../java/com/smartcalendar/model/Task.java | 4 +-- .../service/NotificationService.java | 10 +++++-- .../smartcalendar/service/UserService.java | 29 +++++++++++++++++++ .../UserControllerIntegrationTest.java | 12 +++++++- 7 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/smartcalendar/dto/EventDto.java create mode 100644 src/main/java/com/smartcalendar/dto/UserShortDto.java diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index 5e39cf2..61a0794 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -2,6 +2,7 @@ import com.smartcalendar.dto.AddCollaborativeEventRequest; import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; import com.smartcalendar.dto.StatisticsData; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; @@ -103,7 +104,7 @@ public ResponseEntity> getTasksByUserId( } @GetMapping("/{userId}/events") - public ResponseEntity> getEventsByUserId( + public ResponseEntity> getEventsByUserId( @PathVariable Long userId, @AuthenticationPrincipal UserDetails userDetails) { User currentUser = userService.findByUsername(userDetails.getUsername()) @@ -112,7 +113,10 @@ public ResponseEntity> getEventsByUserId( return ResponseEntity.status(403).build(); } List events = userService.findEventsByUserId(userId); - return ResponseEntity.ok(events); + List eventDtos = events.stream() + .map(userService::toEventDto) + .toList(); + return ResponseEntity.ok(eventDtos); } @PostMapping("/{userId}/events") @@ -228,7 +232,7 @@ public ResponseEntity> getAllEventsAsDailyTasks( } @PatchMapping("/events/{eventId}/status") - public ResponseEntity updateEventStatus( + public ResponseEntity updateEventStatus( @PathVariable UUID eventId, @RequestBody Map requestBody, @AuthenticationPrincipal UserDetails userDetails) { @@ -243,7 +247,8 @@ public ResponseEntity updateEventStatus( userService.notifyEventUpdated(event, updatedEvent); - return ResponseEntity.ok(updatedEvent); + EventDto updatedEventDto = userService.toEventDto(updatedEvent); + return ResponseEntity.ok(updatedEventDto); } @DeleteMapping("/events/{eventId}") @@ -345,13 +350,17 @@ public ResponseEntity removeInviteFromEvent( } @GetMapping("/me/invites") - public ResponseEntity> getMyInvites(@AuthenticationPrincipal UserDetails userDetails) { + public ResponseEntity> getMyInvites(@AuthenticationPrincipal UserDetails userDetails) { User currentUser = userService.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new RuntimeException("User not found")); List invites = userService.findEventsByInvitee(currentUser.getEmail()); - return ResponseEntity.ok(invites); + List inviteDtos = invites.stream() + .map(userService::toEventDto) + .toList(); + return ResponseEntity.ok(inviteDtos); } + @PostMapping("/events/{eventId}/accept-invite") public ResponseEntity acceptInvite( @PathVariable UUID eventId, diff --git a/src/main/java/com/smartcalendar/dto/EventDto.java b/src/main/java/com/smartcalendar/dto/EventDto.java new file mode 100644 index 0000000..ad89fda --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/EventDto.java @@ -0,0 +1,25 @@ +package com.smartcalendar.dto; + +import com.smartcalendar.model.EventType; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Data +public class EventDto { + private UUID id; + private String title; + private String description; + private LocalDateTime start; + private LocalDateTime end; + private String location; + private EventType type; + private LocalDateTime creationTime; + private UserShortDto organizer; + private boolean completed; + private boolean isShared; + private List invitees; + private List participants; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/UserShortDto.java b/src/main/java/com/smartcalendar/dto/UserShortDto.java new file mode 100644 index 0000000..f3d4825 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/UserShortDto.java @@ -0,0 +1,11 @@ +package com.smartcalendar.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserShortDto { + private String username; + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Task.java b/src/main/java/com/smartcalendar/model/Task.java index 20aa0ec..2d51341 100644 --- a/src/main/java/com/smartcalendar/model/Task.java +++ b/src/main/java/com/smartcalendar/model/Task.java @@ -25,8 +25,8 @@ public class Task { private boolean completed; - private LocalDateTime dueDateTime; // дедлайн с точностью до времени - private Boolean allDay = false; // если true — задача только на день (игнорировать время) + private LocalDateTime dueDateTime; + private Boolean allDay = false; private LocalDateTime creationTime = LocalDateTime.now(); diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java index 9ec5f85..fd66f2b 100644 --- a/src/main/java/com/smartcalendar/service/NotificationService.java +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -1,16 +1,22 @@ package com.smartcalendar.service; import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class NotificationService { - // private final JavaMailSender mailSender; + private final JavaMailSender mailSender; // private final PushNotificationService pushNotificationService; public void sendEmail(String to, String subject, String text) { - System.out.println("[STUB] Email to: " + to + ", subject: " + subject + ", text: " + text); + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + mailSender.send(message); } public void sendPush(String deviceToken, String title, String body) { diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 0bcd4ea..6741b80 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -1,7 +1,9 @@ package com.smartcalendar.service; import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; import com.smartcalendar.dto.StatisticsData; +import com.smartcalendar.dto.UserShortDto; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; import com.smartcalendar.model.User; @@ -386,4 +388,31 @@ private void notifyAllEventUsers(Event event, String subject, String text) { } } } + + public EventDto toEventDto(Event event) { + EventDto dto = new EventDto(); + dto.setId(event.getId()); + dto.setTitle(event.getTitle()); + dto.setDescription(event.getDescription()); + dto.setStart(event.getStart()); + dto.setEnd(event.getEnd()); + dto.setLocation(event.getLocation()); + dto.setType(event.getType()); + dto.setCreationTime(event.getCreationTime()); + dto.setCompleted(event.isCompleted()); + dto.setShared(event.isShared()); + dto.setInvitees(event.getInvitees()); + + if (event.getOrganizer() != null) { + dto.setOrganizer(new UserShortDto(event.getOrganizer().getUsername(), event.getOrganizer().getEmail())); + } + if (event.getParticipants() != null) { + dto.setParticipants( + event.getParticipants().stream() + .map(u -> new UserShortDto(u.getUsername(), u.getEmail())) + .toList() + ); + } + return dto; + } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java index d8905ea..b504e5c 100644 --- a/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; import com.smartcalendar.dto.StatisticsData; +import com.smartcalendar.dto.UserShortDto; import com.smartcalendar.model.Event; import com.smartcalendar.model.EventType; import com.smartcalendar.model.Task; @@ -139,12 +141,20 @@ void testGetEventsByUserId() throws Exception { Event event = new Event(); event.setId(UUID.randomUUID()); event.setOrganizer(user); + + EventDto eventDto = new EventDto(); + eventDto.setId(event.getId()); + eventDto.setTitle("Test Event"); + eventDto.setOrganizer(new UserShortDto(user.getUsername(), user.getEmail())); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); Mockito.when(userService.findEventsByUserId(1L)).thenReturn(List.of(event)); + Mockito.when(userService.toEventDto(event)).thenReturn(eventDto); mockMvc.perform(get("/api/users/1/events")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").exists()); + .andExpect(jsonPath("$[0].id").value(event.getId().toString())) + .andExpect(jsonPath("$[0].organizer.username").value("testuser")); } @Test From 9df3059f6fd90287fdd4b6af29cd370e2a9b98a8 Mon Sep 17 00:00:00 2001 From: DimaRus05 Date: Thu, 19 Jun 2025 17:10:15 +0300 Subject: [PATCH 5/5] collaborative events notification added --- README.md | 82 +++++++++++------ .../smartcalendar/config/SecurityConfig.java | 2 +- .../controller/UserController.java | 4 +- .../service/NotificationService.java | 8 +- .../service/PushNotificationService.java | 59 ------------ .../smartcalendar/service/UserService.java | 90 ++++++++++++++----- src/main/resources/application.properties | 5 +- 7 files changed, 133 insertions(+), 117 deletions(-) delete mode 100644 src/main/java/com/smartcalendar/service/PushNotificationService.java diff --git a/README.md b/README.md index 9323068..5dd5358 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST --- -## Quick Start (Development) +## Quick Start 1. Clone repository: ```bash git clone https://github.com/hse-project-Java-2025/server.git @@ -44,6 +44,7 @@ Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST ```ini JWT_SECRET=your_strong_secret_here CHATGPT_API_KEY=your_openai_api_key + MAIL_PASSWORD=your_smtp_app_password ``` 3. Build and run: ```bash @@ -56,29 +57,34 @@ Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST --- ## Configuration + ### Essential Environment Variables | Variable | Description | Example | |-------------------|-------------------------------------|-----------------------------| -| `JWT_SECRET` | Secret for JWT token signing | `A$ecretKey!123` | -| `CHATGPT_API_KEY` | OpenAI API key | `sk-...` | -| `DB_URL` | Production DB URL (optional) | `jdbc:postgresql://db:5432` | - -### Production Setup -1. Create `application-prod.properties`: - ```properties - spring.datasource.url=jdbc:postgresql://your-db-host:5432/smartcalendar - spring.datasource.username=dbuser - spring.datasource.password=dbpassword - spring.jpa.hibernate.ddl-auto=update - ``` -2. Build executable JAR: - ```bash - ./gradlew clean build - ``` -3. Run with production profile: - ```bash - java -Dspring.profiles.active=prod -jar build/libs/smartcalendar-*.jar - ``` +| `JWT_SECRET` | Secret for JWT token signing | `A$ecretKey!123` | +| `CHATGPT_API_KEY` | OpenAI API key | `sk-...` | +| `MAIL_PASSWORD` | SMTP app password for email sending | `your_app_password` | +| `DB_URL` | Production DB URL (optional) | `jdbc:postgresql://db:5432` | + + +### SMTP Email Notification Setup + +To enable email notifications (for collaborative events, invites, etc.), configure the following SMTP settings in your `application.properties`: + +| Property | Example Value | Description | +|------------------------------------------------|------------------------------|---------------------------------------------------------------------------------------------| +| `spring.mail.host` | `smtp.gmail.com` | SMTP server host (Gmail example) | +| `spring.mail.port` | `587` | SMTP server port (587 for TLS) | +| `spring.mail.username` | `your_email@gmail.com` | Email account used to send notifications | +| `spring.mail.password` | `${MAIL_PASSWORD}` | App password for the email account (set as environment variable) | +| `spring.mail.properties.mail.smtp.auth` | `true` | Enable SMTP authentication | +| `spring.mail.properties.mail.smtp.starttls.enable` | `true` | Enable STARTTLS encryption | +| `spring.mail.from` | `noreply@ttsc.com` | Sender address shown in emails (must match or be an alias for Gmail accounts) | + +**Important notes:** +- For Gmail, you must use an [App Password](https://support.google.com/accounts/answer/185833?hl=en) and have two-factor authentication enabled. +- The value of `spring.mail.from` will only be used if your SMTP provider allows it. Gmail requires this to match your authenticated account or a verified alias. +- For other SMTP providers, adjust the host, port, and credentials accordingly. --- @@ -107,13 +113,18 @@ http://localhost:8080/swagger-ui.html | `/api/users/tasks/{taskId}/status` | PATCH | Update task status | | `/api/users/tasks/{taskId}` | DELETE | Delete task | -### Event Management -| Endpoint | Method | Description | -|---------------------------------------|--------|------------------------------| -| `/api/users/{userId}/events` | GET | Get user's events | -| `/api/users/{userId}/events` | POST | Create new event | -| `/api/users/events/{eventId}` | PATCH | Update event | -| `/api/users/events/{eventId}` | DELETE | Delete event | +### Event Management (including Collaborative Events) +| Endpoint | Method | Description | +|-----------------------------------------------|--------|--------------------------------------------------| +| `/api/users/{userId}/events` | GET | Get user's events (including shared/collaborative)| +| `/api/users/{userId}/events` | POST | Create new event | +| `/api/users/events/{eventId}` | PATCH | Update event | +| `/api/users/events/{eventId}` | DELETE | Delete event | +| `/api/users/events/{eventId}/invite` | POST | Invite user to event (collaboration) | +| `/api/users/events/{eventId}/accept-invite` | POST | Accept event invitation | +| `/api/users/events/{eventId}/remove-invite` | POST | Remove invitation for user | +| `/api/users/events/{eventId}/remove-participant` | POST | Remove participant from event | +| `/api/users/me/invites` | GET | Get events you are invited to | ### OpenAI Integration | Endpoint | Method | Description | @@ -124,6 +135,17 @@ http://localhost:8080/swagger-ui.html --- +## Collaborative Events + +The SmartCalendar supports full collaboration on events: +- **Invite users** to your events by username or email. +- **Accept or decline invitations** to shared events. +- **Remove participants** or invitations at any time. +- **Automatic email notifications** are sent for all key actions (invitation, joining, removal, updates, deletion) with detailed event info. +- **All collaborative features are available via REST API** (see Event Management section above). + +--- + ## Testing Run tests with: ```bash @@ -133,7 +155,9 @@ Run tests with: - External services (OpenAI) are mocked - Test coverage reports: `build/reports/tests` ---- +### Postman Collection + +You can also test all endpoints and collaborative event scenarios using our [Postman collection](https://warped-spaceship-772679.postman.co/workspace/Team-Workspace~558e4b04-2021-4e54-894c-0ad8890eda3d/collection/43149440-fdb46307-d6af-4895-bd4b-5b871c1f6962?action=share&creator=43149440&active-environment=43149440-f5aa59ad-f5b0-484f-923a-2d9403843293) ## CI/CD Pipeline GitHub Actions workflow (`.github/workflows/ci.yml`): diff --git a/src/main/java/com/smartcalendar/config/SecurityConfig.java b/src/main/java/com/smartcalendar/config/SecurityConfig.java index 212f19f..29faad3 100644 --- a/src/main/java/com/smartcalendar/config/SecurityConfig.java +++ b/src/main/java/com/smartcalendar/config/SecurityConfig.java @@ -86,7 +86,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("*")); //TODO: конкретные домены + configuration.setAllowedOrigins(List.of("*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of("Authorization")); diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index 61a0794..fafa0ec 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -323,7 +323,7 @@ public ResponseEntity inviteUserToEvent( event.setShared(true); userService.saveEvent(event); - + userService.notifyInvitees(event); return ResponseEntity.ok(Map.of("invited", user.getUsername())); } @@ -378,6 +378,7 @@ public ResponseEntity acceptInvite( event.getParticipants().add(currentUser); } userService.saveEvent(event); + userService.notifyUserAddedToEvent(currentUser, event, currentUser.getDeviceToken()); return ResponseEntity.ok(Map.of("accepted", true)); } @@ -409,6 +410,7 @@ public ResponseEntity removeParticipantFromEvent( boolean removed = event.getParticipants() != null && event.getParticipants().remove(user); if (removed) { userService.saveEvent(event); + userService.notifyUserRemovedFromEvent(user, event, user.getDeviceToken()); return ResponseEntity.ok(Map.of("removedParticipant", user.getUsername())); } else { return ResponseEntity.badRequest().body(Map.of("error", "User is not a participant")); diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java index fd66f2b..18b396a 100644 --- a/src/main/java/com/smartcalendar/service/NotificationService.java +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -1,6 +1,7 @@ package com.smartcalendar.service; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; @@ -9,13 +10,18 @@ @RequiredArgsConstructor public class NotificationService { private final JavaMailSender mailSender; - // private final PushNotificationService pushNotificationService; + + @Value("${spring.mail.from:}") + private String fromAddress; public void sendEmail(String to, String subject, String text) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(to); message.setSubject(subject); message.setText(text); + if (fromAddress != null && !fromAddress.isBlank()) { + message.setFrom(fromAddress); + } mailSender.send(message); } diff --git a/src/main/java/com/smartcalendar/service/PushNotificationService.java b/src/main/java/com/smartcalendar/service/PushNotificationService.java deleted file mode 100644 index 683b957..0000000 --- a/src/main/java/com/smartcalendar/service/PushNotificationService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.smartcalendar.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.auth.oauth2.GoogleCredentials; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.util.ResourceUtils; -import org.springframework.web.client.RestTemplate; - -import java.io.FileInputStream; -import java.util.List; -import java.util.Map; - -@Service -public class PushNotificationService { - - @Value("${firebase.project-id}") - private String projectId; - - @Value("${firebase.credentials.path}") - private String credentialsPath; - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - public void sendPush(String deviceToken, String title, String body) { - try { - String url = "https://fcm.googleapis.com/v1/projects/" + projectId + "/messages:send"; - - GoogleCredentials credentials = GoogleCredentials - .fromStream(new FileInputStream(ResourceUtils.getFile(credentialsPath))) - .createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging")); - credentials.refreshIfExpired(); - String accessToken = credentials.getAccessToken().getTokenValue(); - - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - headers.setContentType(MediaType.APPLICATION_JSON); - - Map notification = Map.of( - "title", title, - "body", body - ); - Map message = Map.of( - "token", deviceToken, - "notification", notification - ); - Map bodyMap = Map.of("message", message); - - String jsonBody = objectMapper.writeValueAsString(bodyMap); - - HttpEntity request = new HttpEntity<>(jsonBody, headers); - RestTemplate restTemplate = new RestTemplate(); - restTemplate.postForEntity(url, request, String.class); - } catch (Exception e) { - e.printStackTrace(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 6741b80..0362e4f 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -22,6 +22,7 @@ import java.util.*; import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; @Service @RequiredArgsConstructor @@ -33,7 +34,6 @@ public class UserService { private final StatisticsService statisticsService; private final StatisticsRepository statisticsRepository; private final NotificationService notificationService; - private final PushNotificationService pushNotificationService; public User createUser(User user) { if (userRepository.existsByUsername(user.getUsername())) { @@ -287,9 +287,12 @@ public Optional findByEmail(String email) { public void notifyUserAddedToEvent(User user, Event event, String deviceToken) { if (user.getEmail() != null && !user.getEmail().isBlank()) { - String subject = "You have been added to event: " + event.getTitle(); - String text = "Hello, you have been added to the event \"" + event.getTitle() + "\" by " + - (event.getOrganizer() != null ? event.getOrganizer().getUsername() : "system") + "."; + String subject = "[TimeTamer SmartCalendar] You have been added to event: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been added to the event:", + event, + event.getOrganizer() + ); notificationService.sendEmail(user.getEmail(), subject, text); } if (deviceToken != null && !deviceToken.isBlank()) { @@ -302,19 +305,52 @@ public void notifyUserAddedToEvent(User user, Event event, String deviceToken) { } public void notifyUserRemovedFromEvent(User user, Event event, String deviceToken) { - sendEventEmail(user, event, "You have been removed from event: ", "removed from the event"); - notificationService.sendPush(deviceToken, - "Removed from event", - "You have been removed from event: " + event.getTitle()); - } - - private void sendEventEmail(User user, Event event, String subjectPrefix, String actionText) { if (user.getEmail() != null && !user.getEmail().isBlank()) { - String subject = subjectPrefix + event.getTitle(); - String text = "Hello, you have been " + actionText + " \"" + event.getTitle() + "\"" - + (event.getOrganizer() != null ? " by " + event.getOrganizer().getUsername() : "") + "."; + String subject = "[TimeTamer SmartCalendar] You have been removed from event: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been removed from the event:", + event, + event.getOrganizer() + ); notificationService.sendEmail(user.getEmail(), subject, text); } + if (deviceToken != null && !deviceToken.isBlank()) { + notificationService.sendPush(deviceToken, + "Removed from event", + "You have been removed from event: " + event.getTitle()); + } + } + + private String buildEventNotificationText(String action, Event event, User organizer) { + DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + String organizerName = organizer != null + ? organizer.getUsername() + " (" + organizer.getEmail() + ")" + : "a user"; + String eventType = event.getType() != null ? event.getType().name() : "COMMON"; + String start = event.getStart() != null ? event.getStart().format(dtf) : "unspecified"; + String end = event.getEnd() != null ? event.getEnd().format(dtf) : "unspecified"; + String location = event.getLocation() != null ? event.getLocation() : "unspecified"; + + return String.format( + "Hello!\n\n" + + "%s\n\n" + + "Title: %s\n" + + "Type: %s\n" + + "Start: %s\n" + + "End: %s\n" + + "Location: %s\n\n" + + "Description: %s\n\n" + + "Organizer: %s\n\n" + + "This is an automatic notification from TimeTamer SmartCalendar.\n", + action, + event.getTitle(), + eventType, + start, + end, + location, + event.getDescription() != null ? event.getDescription() : "No description", + organizerName + ); } public List findEventsByInvitee(String email) { @@ -327,10 +363,12 @@ public List findEventsByInvitee(String email) { public void notifyInvitees(Event event) { if (event.getInvitees() == null || event.getInvitees().isEmpty()) return; - String subject = "You have been invited to the event: " + event.getTitle(); - String text = "You have been invited by " + - (event.getOrganizer() != null ? event.getOrganizer().getUsername() : "a user") + - " to the event \"" + event.getTitle() + "\"."; + String subject = "[TimeTamer SmartCalendar] Event invitation: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been invited to the event:", + event, + event.getOrganizer() + ); for (String email : event.getInvitees()) { Optional userOpt = findByEmail(email); @@ -360,14 +398,22 @@ public Event saveEvent(Event event) { } public void notifyEventDeleted(Event event) { - String subject = "Event \"" + event.getTitle() + "\" has been deleted"; - String text = "The organizer " + event.getOrganizer().getUsername() + " has deleted the event."; + String subject = "[TimeTamer SmartCalendar] Event \"" + event.getTitle() + "\" has been deleted"; + String text = buildEventNotificationText( + "The event has been deleted:", + event, + event.getOrganizer() + ); notifyAllEventUsers(event, subject, text); } public void notifyEventUpdated(Event oldEvent, Event newEvent) { - String subject = "Event \"" + oldEvent.getTitle() + "\" has been updated"; - String text = "The organizer " + oldEvent.getOrganizer().getUsername() + " has updated the event details."; + String subject = "[TimeTamer SmartCalendar] Event \"" + oldEvent.getTitle() + "\" has been updated"; + String text = buildEventNotificationText( + "The event has been updated:", + newEvent, + oldEvent.getOrganizer() + ); notifyAllEventUsers(oldEvent, subject, text); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6d7a8fc..6f4a937 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -67,7 +67,4 @@ spring.mail.username=dimarus06122005@gmail.com spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.from=dimarus06122005@gmail.com - -firebase.project-id=timetamer-smarcalendar -firebase.credentials.path=classpath:firebase-service-account.json \ No newline at end of file +spring.mail.from=noreply@ttsc.com \ No newline at end of file