From facade014ae13e6ccca017b0171601fdfd174c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Thu, 4 Dec 2025 08:59:44 +0100 Subject: [PATCH] Notification System --- CLAUDE.md | 2 +- .../fitpub/config/SecurityConfig.java | 4 + .../NotificationsViewController.java | 16 + .../fitpub/service/NotificationService.java | 28 +- src/main/resources/static/js/auth.js | 58 ++ src/main/resources/templates/layout.html | 12 + .../resources/templates/notifications.html | 549 ++++++++++++++++++ 7 files changed, 662 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/controller/NotificationsViewController.java create mode 100644 src/main/resources/templates/notifications.html diff --git a/CLAUDE.md b/CLAUDE.md index 3021b2d..67ab791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -778,7 +778,7 @@ For ActivityPub federated posts and thumbnails: - [x] Follower/following counts (UserController.populateSocialCounts, UserDTO with followersCount/followingCount, frontend displays real counts) - [x] Heart rate chart over time on activity details (Chart.js line chart, elapsed time x-axis, heart rate y-axis) - [x] Speed/pace chart over time on activity details (Chart.js line chart with smoothing, displays speed in km/h with pace in tooltip) -- [ ] Notifications system +- [x] Notifications system (Notification entity, NotificationRepository, NotificationService, NotificationController REST API, notifications.html UI, notification bell in nav with unread count, polling every 30s, mark as read/delete, all/unread filter tabs) - [ ] Enhanced privacy controls UI - [ ] Follow/unfollow buttons on user profiles - [ ] Activity visibility to followers (implement FOLLOWERS visibility enforcement) diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 6bfd8ef..26e05b5 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -59,6 +59,7 @@ public class SecurityConfig { .requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll() .requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side .requestMatchers("/discover").permitAll() // User discovery page + .requestMatchers("/notifications").permitAll() // Auth checked client-side // Public endpoints - ActivityPub federation .requestMatchers("/.well-known/**").permitAll() @@ -97,6 +98,9 @@ public class SecurityConfig { .requestMatchers(HttpMethod.POST, "/api/activities/*/comments").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/activities/*/comments/*").authenticated() + // Protected endpoints - Notifications API + .requestMatchers("/api/notifications/**").authenticated() + // Protected endpoints - Activities API (upload, edit, delete) .requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated() .requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated() diff --git a/src/main/java/org/operaton/fitpub/controller/NotificationsViewController.java b/src/main/java/org/operaton/fitpub/controller/NotificationsViewController.java new file mode 100644 index 0000000..937515d --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/NotificationsViewController.java @@ -0,0 +1,16 @@ +package org.operaton.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Controller for notifications-related web pages + */ +@Controller +public class NotificationsViewController { + + @GetMapping("/notifications") + public String notifications() { + return "notifications"; + } +} diff --git a/src/main/java/org/operaton/fitpub/service/NotificationService.java b/src/main/java/org/operaton/fitpub/service/NotificationService.java index ca36f45..76fc516 100644 --- a/src/main/java/org/operaton/fitpub/service/NotificationService.java +++ b/src/main/java/org/operaton/fitpub/service/NotificationService.java @@ -38,8 +38,16 @@ public class NotificationService { */ @Transactional public void createActivityLikedNotification(Activity activity, String likerActorUri) { + // Get the activity owner + User activityOwner = userRepository.findById(activity.getUserId()) + .orElse(null); + if (activityOwner == null) { + log.warn("Could not find activity owner for activity: {}", activity.getId()); + return; + } + // Don't notify if user liked their own activity - String activityOwnerUri = activity.getUser().getActorUri(baseUrl); + String activityOwnerUri = activityOwner.getActorUri(baseUrl); if (activityOwnerUri.equals(likerActorUri)) { return; } @@ -52,7 +60,7 @@ public class NotificationService { } Notification notification = Notification.builder() - .user(activity.getUser()) + .user(activityOwner) .type(Notification.NotificationType.ACTIVITY_LIKED) .actorUri(likerActorUri) .actorDisplayName(actorInfo.displayName) @@ -63,7 +71,7 @@ public class NotificationService { .build(); notificationRepository.save(notification); - log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username); + log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activityOwner.getUsername(), actorInfo.username); } /** @@ -75,8 +83,16 @@ public class NotificationService { */ @Transactional public void createActivityCommentedNotification(Activity activity, Comment comment, String commenterActorUri) { + // Get the activity owner + User activityOwner = userRepository.findById(activity.getUserId()) + .orElse(null); + if (activityOwner == null) { + log.warn("Could not find activity owner for activity: {}", activity.getId()); + return; + } + // Don't notify if user commented on their own activity - String activityOwnerUri = activity.getUser().getActorUri(baseUrl); + String activityOwnerUri = activityOwner.getActorUri(baseUrl); if (activityOwnerUri.equals(commenterActorUri)) { return; } @@ -95,7 +111,7 @@ public class NotificationService { } Notification notification = Notification.builder() - .user(activity.getUser()) + .user(activityOwner) .type(Notification.NotificationType.ACTIVITY_COMMENTED) .actorUri(commenterActorUri) .actorDisplayName(actorInfo.displayName) @@ -108,7 +124,7 @@ public class NotificationService { .build(); notificationRepository.save(notification); - log.debug("Created ACTIVITY_COMMENTED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username); + log.debug("Created ACTIVITY_COMMENTED notification for user {} from {}", activityOwner.getUsername(), actorInfo.username); } /** diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js index 09da907..e2be6e3 100644 --- a/src/main/resources/static/js/auth.js +++ b/src/main/resources/static/js/auth.js @@ -216,6 +216,7 @@ const FitPubAuth = { const usernameDisplay = document.getElementById('usernameDisplay'); const myActivitiesLink = document.getElementById('myActivitiesLink'); const uploadLink = document.getElementById('uploadLink'); + const notificationsBell = document.getElementById('notificationsBell'); if (this.isAuthenticated()) { // Show authenticated menu, hide guest menu @@ -236,16 +237,27 @@ const FitPubAuth = { uploadLink.parentElement.style.display = ''; } + // Show notifications bell + if (notificationsBell) { + notificationsBell.classList.remove('d-none'); + } + // Display username const username = this.getUsername(); if (usernameDisplay && username) { usernameDisplay.textContent = username; } + + // Start polling for unread notifications + this.startNotificationPolling(); } else { // Show guest menu, hide authenticated menu if (authUserMenu) { authUserMenu.classList.add('d-none'); } + if (notificationsBell) { + notificationsBell.classList.add('d-none'); + } if (guestMenu) { guestMenu.style.display = ''; } @@ -329,6 +341,52 @@ const FitPubAuth = { */ refreshPage: function() { window.location.reload(); + }, + + /** + * Start polling for unread notifications + */ + notificationPollInterval: null, + + startNotificationPolling: function() { + // Stop any existing polling + this.stopNotificationPolling(); + + // Initial fetch + this.updateUnreadNotificationCount(); + + // Poll every 30 seconds + this.notificationPollInterval = setInterval(() => { + this.updateUnreadNotificationCount(); + }, 30000); + }, + + stopNotificationPolling: function() { + if (this.notificationPollInterval) { + clearInterval(this.notificationPollInterval); + this.notificationPollInterval = null; + } + }, + + async updateUnreadNotificationCount() { + try { + const response = await this.authenticatedFetch('/api/notifications/unread/count'); + if (response.ok) { + const data = await response.json(); + const badge = document.getElementById('navNotificationCount'); + if (badge) { + if (data.count > 0) { + badge.textContent = data.count > 99 ? '99+' : data.count; + badge.style.display = 'inline-block'; + } else { + badge.style.display = 'none'; + } + } + } + } catch (error) { + // Silently fail - don't spam console with errors + console.debug('Failed to fetch notification count:', error); + } } }; diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 60f1413..77df72c 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -74,6 +74,18 @@