diff --git a/test/api/unit/models/userNotification.test.js b/test/api/unit/models/userNotification.test.js index 772e8dba8f..8e9405ee0a 100644 --- a/test/api/unit/models/userNotification.test.js +++ b/test/api/unit/models/userNotification.test.js @@ -50,5 +50,59 @@ describe('UserNotification Model', () => { expect(safeNotifications[0].type).to.equal('NEW_CHAT_MESSAGE'); expect(safeNotifications[0].id).to.equal('123'); }); + + it('removes duplicate STREAK_ACHIEVEMENT notifications', () => { + // Fixes issue #13325 - Users receiving duplicate streak achievement notifications + const notifications = [ + new UserNotification({ + type: 'STREAK_ACHIEVEMENT', + id: 123, + data: {}, + }), + new UserNotification({ + type: 'STREAK_ACHIEVEMENT', + id: 456, + data: {}, + }), + new UserNotification({ + type: 'CRON', + id: 789, + data: {}, + }), // different type, should be kept + ]; + + const safeNotifications = UserNotification.cleanupCorruptData(notifications); + expect(safeNotifications.length).to.equal(2); + expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT'); + expect(safeNotifications[0].id).to.equal('123'); + expect(safeNotifications[1].type).to.equal('CRON'); + expect(safeNotifications[1].id).to.equal('789'); + }); + + it('handles multiple STREAK_ACHIEVEMENT duplicates correctly', () => { + // Test case: 3 duplicate STREAK_ACHIEVEMENT notifications + const notifications = [ + new UserNotification({ + type: 'STREAK_ACHIEVEMENT', + id: 111, + data: {}, + }), + new UserNotification({ + type: 'STREAK_ACHIEVEMENT', + id: 222, + data: {}, + }), + new UserNotification({ + type: 'STREAK_ACHIEVEMENT', + id: 333, + data: {}, + }), + ]; + + const safeNotifications = UserNotification.cleanupCorruptData(notifications); + expect(safeNotifications.length).to.equal(1); + expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT'); + expect(safeNotifications[0].id).to.equal('111'); // Keep first one + }); }); }); diff --git a/website/client/src/components/notifications.vue b/website/client/src/components/notifications.vue index 2639680d48..27d90d502a 100644 --- a/website/client/src/components/notifications.vue +++ b/website/client/src/components/notifications.vue @@ -330,6 +330,7 @@ export default { handledNotifications, isInitialLoadComplete: false, pendingRebirthNotification: null, + lastShownStreakCount: null, // Track last shown streak to prevent duplicates }; }, computed: { @@ -737,6 +738,13 @@ export default { } break; case 'STREAK_ACHIEVEMENT': + // Client-side deduplication: prevent showing duplicate streak achievements + if (this.lastShownStreakCount === this.user.achievements.streak) { + // Same streak already shown, skip this notification + break; + } + this.lastShownStreakCount = this.user.achievements.streak; + this.text(`${this.$t('streaks')}: ${this.user.achievements.streak}`, () => { this.$root.$emit('bv::show::modal', 'streak'); }, this.user.preferences.suppressModals.streak); diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index dc270231bf..dfaf4462e4 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -143,6 +143,16 @@ schema.statics.cleanupCorruptData = function cleanupCorruptNotificationsData (no return false; }); + // Remove duplicate STREAK_ACHIEVEMENT notifications + // Deduplicates by keeping only the first occurrence of STREAK_ACHIEVEMENT notification + filteredNotifications = _.uniqWith(filteredNotifications, (val, otherVal) => { + if (val.type === 'STREAK_ACHIEVEMENT' && val.type === otherVal.type) { + // If both are streak achievements, they are duplicates + return true; + } + return false; + }); + return filteredNotifications; };