From 13ad5a5bbeb531e31555f722acf84d8024111def Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 11:12:55 +0300 Subject: [PATCH] feat: Notifications support for invitations --- backend/.gitignore | 3 +- backend/pom.xml | 5 + .../online/wesal/wesal/WesalApplication.java | 2 + .../wesal/wesal/config/FirebaseConfig.java | 29 +++++ .../wesal/service/FCMNotificationService.java | 105 ++++++++++++++++++ .../wesal/service/InvitationService.java | 37 ++++++ 6 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/online/wesal/wesal/config/FirebaseConfig.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/FCMNotificationService.java diff --git a/backend/.gitignore b/backend/.gitignore index 239f1c2..f2b0a9f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -32,4 +32,5 @@ build/ ### VS Code ### .vscode/ .env -env.properties \ No newline at end of file +env.properties +firebase-service-account.json \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index c085247..1af2abd 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -90,6 +90,11 @@ springdoc-openapi-starter-webmvc-ui 2.3.0 + + com.google.firebase + firebase-admin + 9.2.0 + diff --git a/backend/src/main/java/online/wesal/wesal/WesalApplication.java b/backend/src/main/java/online/wesal/wesal/WesalApplication.java index 5b2a4b8..f25819c 100644 --- a/backend/src/main/java/online/wesal/wesal/WesalApplication.java +++ b/backend/src/main/java/online/wesal/wesal/WesalApplication.java @@ -2,8 +2,10 @@ package online.wesal.wesal; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class WesalApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/online/wesal/wesal/config/FirebaseConfig.java b/backend/src/main/java/online/wesal/wesal/config/FirebaseConfig.java new file mode 100644 index 0000000..48ca0b3 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/config/FirebaseConfig.java @@ -0,0 +1,29 @@ +package online.wesal.wesal.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; + +@Configuration +public class FirebaseConfig { + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (FirebaseApp.getApps().isEmpty()) { + InputStream serviceAccount = new ClassPathResource("firebase-service-account.json").getInputStream(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + return FirebaseApp.initializeApp(options); + } + return FirebaseApp.getInstance(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/FCMNotificationService.java b/backend/src/main/java/online/wesal/wesal/service/FCMNotificationService.java new file mode 100644 index 0000000..9fd9f90 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/FCMNotificationService.java @@ -0,0 +1,105 @@ +package online.wesal.wesal.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import online.wesal.wesal.entity.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class FCMNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(FCMNotificationService.class); + + @Async + public void sendNotificationToUser(User user, String title, String body) { + if (user.getFcmToken() == null || user.getFcmToken().trim().isEmpty()) { + logger.warn("User {} has no FCM token, skipping notification", user.getDisplayName()); + return; + } + + try { + Message message = Message.builder() + .setToken(user.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .build(); + + + String response = FirebaseMessaging.getInstance().send(message); + logger.info("Successfully sent notification to {}: {}", user.getDisplayName(), response); + } catch (Exception e) { + logger.error("Failed to send notification to {}: {}", user.getDisplayName(), e.getMessage()); + } + } + + @Async + public void sendNotificationToUsers(List users, String title, String body) { + users.forEach(user -> sendNotificationToUser(user, title, body)); + } + + @Async + public void sendInvitationAcceptedNotification(String organizerToken, String organizerName, String accepterName, String tagName) { + String title = "New Attendee! 🎉"; + String body = String.format("%s accepted your invite for %s", accepterName, tagName); + + if (organizerToken == null || organizerToken.trim().isEmpty()) { + logger.warn("User {} has no FCM token, skipping notification", organizerName); + return; + } + + try { + Message message = Message.builder() + .setToken(organizerToken) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + logger.info("Successfully sent notification to {}: {}", organizerName, response); + } catch (Exception e) { + logger.error("Failed to send notification to {}: {}", organizerName, e.getMessage()); + } + } + + @Async + public void sendInvitationFullNotification(List fcmTokens, List displayNames, String tagName, int totalAttendees) { + String title = String.format("You're set for %s! 🚀", tagName); + String body = String.format("%d people have accepted the invitation. Click to see who they are!", totalAttendees); + + for (int i = 0; i < fcmTokens.size(); i++) { + String token = fcmTokens.get(i); + String name = displayNames.get(i); + + if (token == null || token.trim().isEmpty()) { + logger.warn("User {} has no FCM token, skipping notification", name); + continue; + } + + try { + Message message = Message.builder() + .setToken(token) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + logger.info("Successfully sent notification to {}: {}", name, response); + } catch (Exception e) { + logger.error("Failed to send notification to {}: {}", name, e.getMessage()); + } + } + } + +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java index 57fbbdb..ac9638c 100644 --- a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java @@ -35,6 +35,9 @@ public class InvitationService { @Autowired private AttendeeRepository attendeeRepository; + @Autowired + private FCMNotificationService fcmNotificationService; + @Transactional public InvitationResponse createInvitation(CreateInvitationRequest request, String userEmail) { User creator = userRepository.findByEmail(userEmail) @@ -118,7 +121,41 @@ public class InvitationService { attendeeRepository.save(attendee); invitation.setCurrentAttendees(invitation.getCurrentAttendees() + 1); + int newAttendeeCount = invitation.getCurrentAttendees(); invitationRepository.save(invitation); + + // If invitation is now full, notify everyone + if (newAttendeeCount == invitation.getMaxParticipants()) { + // Get all participants (attendees + organizer) + List attendees = attendeeRepository.findByInvitationId(invitationId); + List allParticipants = attendees.stream() + .map(Attendee::getUser) + .collect(Collectors.toList()); + allParticipants.add(invitation.getCreator()); + + // Extract FCM tokens and display names for async processing + List fcmTokens = allParticipants.stream() + .map(User::getFcmToken) + .collect(Collectors.toList()); + List displayNames = allParticipants.stream() + .map(User::getDisplayName) + .collect(Collectors.toList()); + + fcmNotificationService.sendInvitationFullNotification( + fcmTokens, + displayNames, + invitation.getTag().getName(), + newAttendeeCount + ); + } else { + // Send notification to organizer about new attendee (only if not full) + fcmNotificationService.sendInvitationAcceptedNotification( + invitation.getCreator().getFcmToken(), + invitation.getCreator().getDisplayName(), + user.getDisplayName(), + invitation.getTag().getName() + ); + } } @Transactional