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/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/build.gradle b/build.gradle index cd2eb5c..c8ca27e 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,9 @@ 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' + 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/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/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 0b014d5..fafa0ec 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -1,6 +1,8 @@ package com.smartcalendar.controller; +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; @@ -12,9 +14,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.UUID; +import java.util.*; @RestController @RequestMapping("/api/users") @@ -104,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()) @@ -113,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") @@ -127,6 +130,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())); @@ -146,7 +153,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(); } @@ -221,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) { @@ -233,19 +244,27 @@ public ResponseEntity updateEventStatus( } boolean completed = requestBody.get("completed"); Event updatedEvent = userService.updateEventStatus(eventId, completed); - return ResponseEntity.ok(updatedEvent); + + userService.notifyEventUpdated(event, updatedEvent); + + EventDto updatedEventDto = userService.toEventDto(updatedEvent); + return ResponseEntity.ok(updatedEventDto); } @DeleteMapping("/events/{eventId}") 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)); } @@ -276,4 +295,125 @@ public ResponseEntity updateStatistics( userService.updateStatistics(userId, statisticsData); return ResponseEntity.ok().build(); } + + @PostMapping("/events/{eventId}/invite") + public ResponseEntity inviteUserToEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + String loginOrEmail = requestBody.get("loginOrEmail"); + Event event = userService.getEventById(eventId); + + 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() != 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.saveEvent(event); + userService.notifyInvitees(event); + return ResponseEntity.ok(Map.of("invited", user.getUsername())); + } + + @PostMapping("/events/{eventId}/remove-invite") + public ResponseEntity removeInviteFromEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + String loginOrEmail = requestBody.get("loginOrEmail"); + Event event = userService.getEventById(eventId); + + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + + 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()); + List inviteDtos = invites.stream() + .map(userService::toEventDto) + .toList(); + return ResponseEntity.ok(inviteDtos); + } + + + @PostMapping("/events/{eventId}/accept-invite") + public ResponseEntity acceptInvite( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + + if (event.getInvitees() == null || !event.getInvitees().contains(currentUser.getEmail())) { + return ResponseEntity.badRequest().body(Map.of("error", "No invite found for this user")); + } + + event.getInvitees().remove(currentUser.getEmail()); + if (!event.getParticipants().contains(currentUser)) { + event.getParticipants().add(currentUser); + } + userService.saveEvent(event); + userService.notifyUserAddedToEvent(currentUser, event, currentUser.getDeviceToken()); + + 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 (user.getId().equals(currentUser.getId())) { + return ResponseEntity.badRequest().body(Map.of("error", "Organizer cannot be removed")); + } + + 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")); + } + } } \ 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/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/Event.java b/src/main/java/com/smartcalendar/model/Event.java index bee786a..b7c7b46 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,19 @@ public class Event { private User organizer; 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", + 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/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/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 6926e17..1a7ae3b 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\", " + @@ -142,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); } } @@ -191,6 +196,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."; 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..18b396a --- /dev/null +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -0,0 +1,31 @@ +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; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final JavaMailSender mailSender; + + @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); + } + + public void sendPush(String deviceToken, String title, String 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/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 8aa3b65..0362e4f 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; @@ -9,6 +11,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,11 +20,9 @@ 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; +import java.time.format.DateTimeFormatter; @Service @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final StatisticsService statisticsService; private final StatisticsRepository statisticsRepository; + private final NotificationService notificationService; public User createUser(User user) { if (userRepository.existsByUsername(user.getUsername())) { @@ -158,7 +160,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) { @@ -276,4 +284,181 @@ 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 = "[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()) { + notificationService.sendPush( + deviceToken, + "Added to event", + "You have been added to event: " + event.getTitle() + ); + } + } + + public void notifyUserRemovedFromEvent(User user, Event event, String deviceToken) { + if (user.getEmail() != null && !user.getEmail().isBlank()) { + 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) { + 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 = "[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); + 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 = "[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 = "[TimeTamer SmartCalendar] Event \"" + oldEvent.getTitle() + "\" has been updated"; + String text = buildEventNotificationText( + "The event has been updated:", + newEvent, + oldEvent.getOrganizer() + ); + 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); + } + } + } + + 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/main/resources/application.properties b/src/main/resources/application.properties index 42b6d97..6f4a937 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,4 +56,15 @@ 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=noreply@ttsc.com \ 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