From d373d576e35b16552fc4d417639aedb7c7a7a055 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Sun, 7 Jun 2026 01:20:49 +0300 Subject: [PATCH] feat: add notification category filters --- .../dto/notification-query.dto.ts | 13 ++++ .../notifications.service.spec.ts | 74 +++++++++++++++++++ .../notifications/notifications.service.ts | 49 ++++++++++-- .../schemas/notification.schema.ts | 3 + 4 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/modules/notifications/dto/notification-query.dto.ts b/src/modules/notifications/dto/notification-query.dto.ts index 08eb75c..7b49896 100644 --- a/src/modules/notifications/dto/notification-query.dto.ts +++ b/src/modules/notifications/dto/notification-query.dto.ts @@ -5,6 +5,14 @@ import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; import { toBoolean } from '../../../common/utils/query-transform.util'; import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema'; +export const NOTIFICATION_CATEGORIES = [ + 'interactions', + 'messages', + 'follows', + 'follow_requests', +] as const; +export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]; + export class NotificationQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ default: false }) @IsOptional() @@ -21,4 +29,9 @@ export class NotificationQueryDto extends PaginationQueryDto { @IsOptional() @IsString() resourceType?: string; + + @ApiPropertyOptional({ enum: NOTIFICATION_CATEGORIES }) + @IsOptional() + @IsEnum(NOTIFICATION_CATEGORIES) + category?: NotificationCategory; } diff --git a/src/modules/notifications/notifications.service.spec.ts b/src/modules/notifications/notifications.service.spec.ts index a0d68d5..22a546a 100644 --- a/src/modules/notifications/notifications.service.spec.ts +++ b/src/modules/notifications/notifications.service.spec.ts @@ -107,6 +107,80 @@ describe('NotificationsService', () => { expect(notificationsRepository.countUnread).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); }); + it('filters notifications by interactions category', async () => { + const notificationsRepository = { + findMine: jest.fn().mockResolvedValue([]), + countMine: jest.fn().mockResolvedValue(0), + countUnread: jest.fn().mockResolvedValue(0), + }; + const notificationsGateway = { + emitUnreadCount: jest.fn(), + }; + + const service = new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ); + + await service.getMine('507f1f77bcf86cd799439011', { + page: 1, + limit: 20, + category: 'interactions', + }); + + expect(notificationsRepository.findMine).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + { + type: { + $in: [ + 'like', + 'comment', + 'reply', + 'mention', + 'save', + 'share', + 'collaboration_request', + 'system', + ], + }, + }, + 0, + 20, + { createdAt: -1 }, + ); + }); + + it('keeps type filter priority over category for backward compatibility', async () => { + const notificationsRepository = { + findMine: jest.fn().mockResolvedValue([]), + countMine: jest.fn().mockResolvedValue(0), + countUnread: jest.fn().mockResolvedValue(0), + }; + const notificationsGateway = { + emitUnreadCount: jest.fn(), + }; + + const service = new NotificationsService( + notificationsRepository as any, + notificationsGateway as any, + ); + + await service.getMine('507f1f77bcf86cd799439011', { + page: 1, + limit: 20, + type: 'message', + category: 'interactions', + }); + + expect(notificationsRepository.findMine).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + { type: 'message' }, + 0, + 20, + { createdAt: -1 }, + ); + }); + it('creates system notifications with system resource mapping when requested', async () => { const notificationsRepository = { create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }), diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 6baa7ce..a2cf488 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -3,11 +3,27 @@ import { Types } from 'mongoose'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { CreateNotificationDto } from './dto/create-notification.dto'; -import { NotificationQueryDto } from './dto/notification-query.dto'; +import { NotificationCategory, NotificationQueryDto } from './dto/notification-query.dto'; import { NotificationsGateway } from './notifications.gateway'; import { NotificationsRepository } from './notifications.repository'; import { NotificationType } from './schemas/notification.schema'; +const NOTIFICATION_CATEGORY_TYPES: Record = { + interactions: [ + 'like', + 'comment', + 'reply', + 'mention', + 'save', + 'share', + 'collaboration_request', + 'system', + ], + messages: ['message'], + follows: ['follow'], + follow_requests: ['follow_request', 'follow_request_approved', 'follow_request_rejected'], +}; + @Injectable() export class NotificationsService { constructor( @@ -162,9 +178,7 @@ export class NotificationsService { if (typeof query.read === 'boolean') { filter.read = query.read; } - if (query.type) { - filter.type = query.type; - } + this.applyTypeOrCategoryFilter(filter, query); if (query.resourceType) { filter.resourceType = query.resourceType.trim(); } @@ -197,9 +211,7 @@ export class NotificationsService { if (typeof query.read === 'boolean') { filter.read = query.read; } - if (query.type) { - filter.type = query.type; - } + this.applyTypeOrCategoryFilter(filter, query); if (query.resourceType) { filter.resourceType = query.resourceType.trim(); } @@ -287,6 +299,12 @@ export class NotificationsService { return 'Notification'; case 'collaboration_request': return 'Collaboration request'; + case 'follow_request': + return 'Follow request'; + case 'follow_request_approved': + return 'Follow request approved'; + case 'follow_request_rejected': + return 'Follow request rejected'; default: return 'Notification'; } @@ -295,6 +313,9 @@ export class NotificationsService { private resolveResourceType(type: NotificationType): string { switch (type) { case 'follow': + case 'follow_request': + case 'follow_request_approved': + case 'follow_request_rejected': return 'user'; case 'message': return 'conversation'; @@ -330,4 +351,18 @@ export class NotificationsService { return ''; } + + private applyTypeOrCategoryFilter( + filter: Record, + query: NotificationQueryDto, + ): void { + if (query.type) { + filter.type = query.type; + return; + } + + if (query.category) { + filter.type = { $in: NOTIFICATION_CATEGORY_TYPES[query.category] }; + } + } } diff --git a/src/modules/notifications/schemas/notification.schema.ts b/src/modules/notifications/schemas/notification.schema.ts index f0205b5..e08da0c 100644 --- a/src/modules/notifications/schemas/notification.schema.ts +++ b/src/modules/notifications/schemas/notification.schema.ts @@ -15,6 +15,9 @@ export const NOTIFICATION_TYPES = [ 'reply', 'system', 'collaboration_request', + 'follow_request', + 'follow_request_approved', + 'follow_request_rejected', ] as const; export type NotificationType = (typeof NOTIFICATION_TYPES)[number];