Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.hcmus.forumus_backend.controller;

import com.hcmus.forumus_backend.dto.notification.NotificationTriggerRequest;
import com.hcmus.forumus_backend.service.NotificationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/notifications")
@CrossOrigin
public class NotificationController {

private final NotificationService notificationService;

public NotificationController(NotificationService notificationService) {
this.notificationService = notificationService;
}

@PostMapping("")
public ResponseEntity<String> triggerNotification(@RequestBody NotificationTriggerRequest request) {
boolean success = notificationService.triggerNotification(request);
if (success) {
return ResponseEntity.ok("Notification triggered successfully");
} else {
return ResponseEntity.badRequest().body("Failed to trigger notification");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.hcmus.forumus_backend.dto.notification;

public class NotificationTriggerRequest {
private String type; // UPVOTE, COMMENT, REPLY
private String actorId;
private String actorName;
private String targetId; // Post ID or Comment ID
private String targetUserId; // The user to notify
private String previewText; // Snippet of post title or comment

public NotificationTriggerRequest() {
}

public NotificationTriggerRequest(String type, String actorId, String actorName, String targetId, String targetUserId, String previewText) {
this.type = type;
this.actorId = actorId;
this.actorName = actorName;
this.targetId = targetId;
this.targetUserId = targetUserId;
this.previewText = previewText;
}

public String getType() {
return type;
}

public void setType(String type) {
this.type = type;
}

public String getActorId() {
return actorId;
}

public void setActorId(String actorId) {
this.actorId = actorId;
}

public String getActorName() {
return actorName;
}

public void setActorName(String actorName) {
this.actorName = actorName;
}

public String getTargetId() {
return targetId;
}

public void setTargetId(String targetId) {
this.targetId = targetId;
}

public String getTargetUserId() {
return targetUserId;
}

public void setTargetUserId(String targetUserId) {
this.targetUserId = targetUserId;
}

public String getPreviewText() {
return previewText;
}

public void setPreviewText(String previewText) {
this.previewText = previewText;
}
}
48 changes: 48 additions & 0 deletions src/main/java/com/hcmus/forumus_backend/service/FCMService.java
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,52 @@ public void sendMulticastNotification(
logger.error("Failed to send multicast notification", e);
}
}
/**
* Send a general notification to a specific device
*
* @param fcmToken The recipient's FCM token
* @param title The notification title
* @param body The notification body
* @param data Additional data payload
* @return true if sent successfully, false otherwise
*/
public boolean sendGeneralNotification(String fcmToken, String title, String body, Map<String, String> data) {
try {
// Create notification
Notification notification = Notification.builder()
.setTitle(title)
.setBody(body)
.build();

// Build the message
Message.Builder messageBuilder = Message.builder()
.setToken(fcmToken)
.setNotification(notification)
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.setNotification(AndroidNotification.builder()
.setSound("default")
.setChannelId("general_notifications")
.build())
.build());

if (data != null && !data.isEmpty()) {
messageBuilder.putAllData(data);
}

Message message = messageBuilder.build();

// Send the message
String response = FirebaseMessaging.getInstance().send(message);
logger.info("Successfully sent general notification: {}", response);
return true;

} catch (FirebaseMessagingException e) {
logger.error("Failed to send general notification to token: {}", fcmToken, e);
return false;
} catch (Exception e) {
logger.error("Unexpected error sending general notification", e);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.hcmus.forumus_backend.service;

import com.google.cloud.Timestamp;
import com.google.cloud.firestore.Firestore;
import com.hcmus.forumus_backend.dto.notification.NotificationTriggerRequest;
import com.hcmus.forumus_backend.model.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;

@Service
public class NotificationService {

private static final Logger logger = LoggerFactory.getLogger(NotificationService.class);

private final Firestore db;
private final UserService userService;
private final FCMService fcmService;

public NotificationService(Firestore db, UserService userService, FCMService fcmService) {
this.db = db;
this.userService = userService;
this.fcmService = fcmService;
}

public boolean triggerNotification(NotificationTriggerRequest request) {
try {
// 1. Validate inputs
if (request.getTargetUserId() == null || request.getTargetUserId().isEmpty()) {
logger.warn("Notification validation failed: targetUserId is missing");
return false;
}

// Don't notify if actor is the same as target user (self-action)
if (request.getTargetUserId().equals(request.getActorId())) {
logger.info("Skipping notification: Actor is target user");
return true; // Use true because it's not an error, just logic
}

// 2. Fetch target user to get FCM token
User targetUser;
try {
targetUser = userService.getUserById(request.getTargetUserId());
} catch (Exception e) {
logger.error("Failed to fetch target user: {}", request.getTargetUserId(), e);
return false;
}

// 3. Prepare Notification Data for Firestore
String notificationId = UUID.randomUUID().toString();
Map<String, Object> notificationData = new HashMap<>();
notificationData.put("id", notificationId);
notificationData.put("type", request.getType());
notificationData.put("actorId", request.getActorId());
notificationData.put("actorName", request.getActorName());
notificationData.put("targetId", request.getTargetId());
notificationData.put("previewText", request.getPreviewText());
notificationData.put("createdAt", Timestamp.now());
notificationData.put("isRead", false);

// 4. Write to Firestore: users/{targetUserId}/notifications/{notificationId}
db.collection("users")
.document(request.getTargetUserId())
.collection("notifications")
.document(notificationId)
.set(notificationData);

logger.info("Notification saved to Firestore: {}", notificationId);

// 5. Send FCM Push Notification
if (targetUser.getFcmToken() != null && !targetUser.getFcmToken().isEmpty()) {
String title = generateNotificationTitle(request);
String body = generateNotificationBody(request);

Map<String, String> data = new HashMap<>();
data.put("type", "general_notification");
data.put("notificationId", notificationId);
data.put("targetId", request.getTargetId()); // Post/Comment ID for deep link
data.put("click_action", "FLUTTER_NOTIFICATION_CLICK"); // Standard, but we handle intent in Android

fcmService.sendGeneralNotification(targetUser.getFcmToken(), title, body, data);
} else {
logger.info("Target user has no FCM token, skipping push notification");
}

return true;

} catch (Exception e) {
logger.error("Error triggering notification", e);
return false;
}
}

private String generateNotificationTitle(NotificationTriggerRequest request) {
return switch (request.getType()) {
case "UPVOTE" -> "New Upvote";
case "COMMENT" -> "New Comment";
case "REPLY" -> "New Reply";
default -> "New Notification";
};
}

private String generateNotificationBody(NotificationTriggerRequest request) {
String actor = request.getActorName() != null ? request.getActorName() : "Someone";
String preview = request.getPreviewText() != null ? request.getPreviewText() : "";
if (preview.length() > 50) preview = preview.substring(0, 50) + "...";

return switch (request.getType()) {
case "UPVOTE" -> actor + " upvoted your post: " + preview;
case "COMMENT" -> actor + " commented on your post: " + preview;
case "REPLY" -> actor + " replied to your comment: " + preview;
default -> actor + " interacted with your content.";
};
}
}