diff --git a/.gitignore b/.gitignore index 459a556..70a2a38 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,8 @@ backend/.db/ backend/init/* backend/**/*temp.java -backend/google-services.json -backend/etf-oglasi-firebase.json +backend/src/main/resources/google-services.json +backend/src/main/resources/etf-oglasi-firebase.json # Java / Eclipse / STS .classpath diff --git a/backend/init.sql b/backend/init.sql index 607a075..a5fb336 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -55,6 +55,32 @@ CREATE TABLE IF NOT EXISTS public.users CONSTRAINT unique_email UNIQUE (email) ); + +CREATE TABLE IF NOT EXISTS public.device_tokens +( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL, + fcm_token text NOT NULL, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT device_tokens_pkey PRIMARY KEY (id), + + CONSTRAINT fk_device_tokens_user + FOREIGN KEY (user_id) + REFERENCES public.users(id) + ON DELETE CASCADE, + + CONSTRAINT uq_device_tokens_fcm_token + UNIQUE (fcm_token) +); + +CREATE INDEX idx_device_tokens_user_id + ON public.device_tokens(user_id); + + + + + ALTER TABLE IF EXISTS public.alt_subject ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects (id) MATCH SIMPLE diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/EtfoglasiServerApplication.java b/backend/src/main/java/dev/ksan/etfoglasiserver/EtfoglasiServerApplication.java index 83334c6..a0645d6 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/EtfoglasiServerApplication.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/EtfoglasiServerApplication.java @@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class EtfoglasiServerApplication { diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/DeviceTokenController.java b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/DeviceTokenController.java new file mode 100644 index 0000000..4cc5607 --- /dev/null +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/DeviceTokenController.java @@ -0,0 +1,45 @@ +package dev.ksan.etfoglasiserver.controller; + +import dev.ksan.etfoglasiserver.model.DeviceToken; +import dev.ksan.etfoglasiserver.model.User; +import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo; +import dev.ksan.etfoglasiserver.repository.UserRepo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/device-tokens") +public class DeviceTokenController { + + @Autowired + private DeviceTokenRepo deviceTokenRepo; + @Autowired + private UserRepo userRepo; + + @PostMapping("/register") + public ResponseEntity register( + @RequestParam UUID userId, + @RequestParam String token + ) { + + User user = userRepo.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + DeviceToken deviceToken = deviceTokenRepo + .findByFcmToken(token) + .orElse(new DeviceToken()); + + deviceToken.setUser(user); + deviceToken.setFcmToken(token); + + deviceTokenRepo.save(deviceToken); + + return ResponseEntity.ok("Token saved"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/model/DeviceToken.java b/backend/src/main/java/dev/ksan/etfoglasiserver/model/DeviceToken.java new file mode 100644 index 0000000..49430b9 --- /dev/null +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/model/DeviceToken.java @@ -0,0 +1,42 @@ +package dev.ksan.etfoglasiserver.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table( + name = "device_tokens", + uniqueConstraints = { + @UniqueConstraint(columnNames = "fcm_token") + } +) +public class DeviceToken { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + @Setter + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Getter + @Setter + @Column(name = "fcm_token", nullable = false) + private String fcmToken; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + @PreUpdate + public void touch() { + this.updatedAt = LocalDateTime.now(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/model/User.java b/backend/src/main/java/dev/ksan/etfoglasiserver/model/User.java index 8721f28..87fdd06 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/model/User.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/model/User.java @@ -3,6 +3,8 @@ package dev.ksan.etfoglasiserver.model; import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import jakarta.persistence.*; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -21,21 +23,43 @@ public class User { private String password; @Column( - nullable = false, - updatable = false, - insertable = false, - columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + nullable = false, + updatable = false, + insertable = false, + columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + ) private LocalDateTime regTime; @Enumerated(EnumType.STRING) @Column(name = "notification_type", nullable = false) - private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION; + private NotificationMethod notificationType = + NotificationMethod.PUSH_NOTIFICATION; - @ManyToMany(fetch = FetchType.LAZY ) - @JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"), - inverseJoinColumns = @JoinColumn(name = "subject_id")) + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "user_subject", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "subject_id") + ) private Set subjectSet; + @OneToMany( + mappedBy = "user", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY + ) + private List deviceTokens = new ArrayList<>(); + + public void addDeviceToken(DeviceToken token) { + deviceTokens.add(token); + token.setUser(this); + } + + public void removeDeviceToken(DeviceToken token) { + deviceTokens.remove(token); + token.setUser(null); + } public User() {} public User(UserCreationDTO user) { @@ -89,4 +113,5 @@ public class User { public NotificationMethod getNotificationMethod() { return notificationType; } + } diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/repository/DeviceTokenRepo.java b/backend/src/main/java/dev/ksan/etfoglasiserver/repository/DeviceTokenRepo.java new file mode 100644 index 0000000..b06b175 --- /dev/null +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/repository/DeviceTokenRepo.java @@ -0,0 +1,20 @@ +package dev.ksan.etfoglasiserver.repository; + +import dev.ksan.etfoglasiserver.model.DeviceToken; +import dev.ksan.etfoglasiserver.model.Entry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DeviceTokenRepo extends JpaRepository { + + List findByUserId(UUID userId); + + Optional findByFcmToken(String fcmToken); + + void deleteByFcmToken(String fcmToken); +} diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/EntryService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/EntryService.java index 570d4cb..c6353a4 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/EntryService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/EntryService.java @@ -63,7 +63,7 @@ public class EntryService { System.out.println("Duplicate"); return; } - subjectService.notify(entry); + subjectService.notifyAsync(entry); } public void updateEntry(EntryDTO entry) { diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java new file mode 100644 index 0000000..b6aae73 --- /dev/null +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java @@ -0,0 +1,60 @@ +package dev.ksan.etfoglasiserver.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import dev.ksan.etfoglasiserver.model.DeviceToken; +import dev.ksan.etfoglasiserver.model.User; +import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final DeviceTokenRepo deviceTokenRepository; + + + public void sendToUser(User user, String title, String body) { + + List tokens = + deviceTokenRepository.findByUserId(user.getId()); + + for (DeviceToken token : tokens) { + + try { + Message message = Message.builder() + .setToken(token.getFcmToken()) + .setNotification( + Notification.builder() + .setTitle(title) + .setBody(body) + .build() + ) + .build(); + + FirebaseMessaging.getInstance().send(message); + + } catch (FirebaseMessagingException e) { + + String errorCode = e.getMessagingErrorCode() != null + ? e.getMessagingErrorCode().name() + : ""; + + System.out.println("Failed token: " + token.getFcmToken()); + + if (errorCode.equals("UNREGISTERED") + || errorCode.equals("INVALID_ARGUMENT")) { + + deviceTokenRepository.delete(token); + System.out.println("Deleted dead token"); + } + } + } + } + +} \ No newline at end of file diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java index db6cc42..183ff1d 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java @@ -8,22 +8,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo; import java.util.List; import java.util.UUID; -import dev.ksan.etfoglasiserver.repository.UserRepo; -import org.simplejavamail.api.mailer.Mailer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service public class SubjectService { @Autowired private SubjectRepo subjectRepo; + @Autowired - private UserRepo userRepo; + private NotificationService notificationService; + @Autowired private UserService userService; - @Autowired - private MailService mailService; + public List getSubjects() { return subjectRepo.findAll(); @@ -67,32 +67,29 @@ public class SubjectService { return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found")); } - public void notify(Entry entry) { - List users = userService.findUsersBySubjectId(entry.getSubject().getId()); - if(users == null || users.isEmpty()) return; - for(User user : users) { - if(user.getNotificationMethod() == NotificationMethod.EMAIL) - this.sendEmailNotification(entry, user); - if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION) - this.sendPushNotification(entry, user); - System.out.println(user.getEmail()); + //TODO move this and make an EventListener for this + + @Async + public void notifyAsync(Entry entry) { + + List users = + userService.findUsersBySubjectId(entry.getSubject().getId()); + + if (users == null || users.isEmpty()) return; + + for (User user : users) { + + if (user.getNotificationMethod() != NotificationMethod.PUSH_NOTIFICATION) + continue; + + notificationService.sendToUser(user, entry.getTitle(), entry.getParagraph()); } - } - public void sendEmailNotification(Entry entry,User user) { - mailService.sendEmail(user.getEmail(),entry.getTitle(),entry.getParagraph()); + public long count() { + + return subjectRepo.count(); } - - //todo - public void sendPushNotification(Entry entry,User user) { - System.out.println("Sending push notification"); - } - - public long count() { - - return subjectRepo.count(); - } }