This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / notifications / application / useCases / queries / GetMyNotificationsUseCase.ts
7.9 kB 232 lines
1import { Result, ok, err } from '../../../../../shared/core/Result'; 2import { UseCase } from '../../../../../shared/core/UseCase'; 3import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4import { AppError } from '../../../../../shared/core/AppError'; 5import { INotificationRepository } from '../../../domain/INotificationRepository'; 6import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; 7import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 8import { ICardQueryRepository } from '../../../../cards/domain/ICardQueryRepository'; 9import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; 10import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; 11import { NotificationItem } from '@semble/types'; 12 13export interface GetMyNotificationsDTO { 14 userId: string; 15 page?: number; 16 limit?: number; 17 unreadOnly?: boolean; 18} 19 20type NotificationItemDTO = NotificationItem; 21 22export interface GetMyNotificationsResponseDTO { 23 notifications: NotificationItemDTO[]; 24 pagination: { 25 currentPage: number; 26 totalPages: number; 27 totalCount: number; 28 hasMore: boolean; 29 limit: number; 30 }; 31 unreadCount: number; 32} 33 34export class ValidationError extends UseCaseError { 35 constructor(message: string) { 36 super(message); 37 } 38} 39 40export class GetMyNotificationsUseCase 41 implements 42 UseCase< 43 GetMyNotificationsDTO, 44 Result< 45 GetMyNotificationsResponseDTO, 46 ValidationError | AppError.UnexpectedError 47 > 48 > 49{ 50 constructor( 51 private notificationRepository: INotificationRepository, 52 private profileService: IProfileService, 53 private cardQueryRepository: ICardQueryRepository, 54 private collectionRepository: ICollectionRepository, 55 ) {} 56 57 async execute( 58 request: GetMyNotificationsDTO, 59 ): Promise< 60 Result< 61 GetMyNotificationsResponseDTO, 62 ValidationError | AppError.UnexpectedError 63 > 64 > { 65 try { 66 const userIdResult = CuratorId.create(request.userId); 67 if (userIdResult.isErr()) { 68 return err( 69 new ValidationError(`Invalid user ID: ${userIdResult.error.message}`), 70 ); 71 } 72 73 const page = request.page || 1; 74 const limit = request.limit || 20; 75 76 // Use enriched query to get notifications with card and collection data 77 const result = await this.notificationRepository.findByRecipientEnriched( 78 userIdResult.value, 79 { 80 page, 81 limit, 82 unreadOnly: request.unreadOnly, 83 }, 84 ); 85 86 if (result.isErr()) { 87 return err(new ValidationError(result.error.message)); 88 } 89 90 const { notifications, totalCount, hasMore, unreadCount } = result.value; 91 92 // Collect all unique user IDs for bulk profile fetching 93 const userIds = new Set<string>(); 94 notifications.forEach((notification) => { 95 userIds.add(notification.actorUserId); 96 userIds.add(notification.cardAuthorId); 97 notification.collections.forEach((collection) => { 98 userIds.add(collection.authorId); 99 }); 100 }); 101 102 // Bulk fetch all profiles 103 const profilePromises = Array.from(userIds).map((id) => 104 this.profileService.getProfile(id), 105 ); 106 const profileResults = await Promise.all(profilePromises); 107 108 // Build profile lookup map 109 const profileMap = new Map<string, any>(); 110 profileResults.forEach((result) => { 111 if (result.isOk()) { 112 profileMap.set(result.value.id, result.value); 113 } 114 }); 115 116 // Transform enriched notifications to DTOs 117 const notificationItems: NotificationItemDTO[] = []; 118 119 for (const notification of notifications) { 120 try { 121 const actorProfile = profileMap.get(notification.actorUserId); 122 const cardAuthorProfile = profileMap.get(notification.cardAuthorId); 123 124 if (!actorProfile || !cardAuthorProfile) { 125 // Skip notifications with missing profiles 126 continue; 127 } 128 129 // Transform collections with author profiles 130 const collections = notification.collections 131 .map((collection) => { 132 const collectionAuthorProfile = profileMap.get( 133 collection.authorId, 134 ); 135 if (!collectionAuthorProfile) { 136 return null; 137 } 138 139 return { 140 id: collection.id, 141 uri: collection.uri, 142 name: collection.name, 143 author: { 144 id: collectionAuthorProfile.id, 145 name: collectionAuthorProfile.name, 146 handle: collectionAuthorProfile.handle, 147 avatarUrl: collectionAuthorProfile.avatarUrl, 148 description: collectionAuthorProfile.bio, 149 }, 150 description: collection.description, 151 cardCount: collection.cardCount, 152 createdAt: collection.createdAt.toISOString(), 153 updatedAt: collection.updatedAt.toISOString(), 154 }; 155 }) 156 .filter( 157 (collection): collection is NonNullable<typeof collection> => 158 collection !== null, 159 ); 160 161 const notificationItem: NotificationItemDTO = { 162 id: notification.id, 163 user: { 164 id: actorProfile.id, 165 name: actorProfile.name, 166 handle: actorProfile.handle, 167 avatarUrl: actorProfile.avatarUrl, 168 description: actorProfile.bio, 169 }, 170 card: { 171 id: notification.cardId, 172 type: 'URL' as const, 173 url: notification.cardUrl, 174 cardContent: { 175 url: notification.cardUrl, 176 title: notification.cardTitle, 177 description: notification.cardDescription, 178 author: notification.cardAuthor, 179 publishedDate: notification.cardPublishedDate?.toISOString(), 180 siteName: notification.cardSiteName, 181 imageUrl: notification.cardImageUrl, 182 type: notification.cardType, 183 retrievedAt: notification.cardRetrievedAt?.toISOString(), 184 doi: notification.cardDoi, 185 isbn: notification.cardIsbn, 186 }, 187 libraryCount: notification.cardLibraryCount, 188 urlLibraryCount: notification.cardUrlLibraryCount, 189 urlInLibrary: notification.cardUrlInLibrary, 190 createdAt: notification.cardCreatedAt.toISOString(), 191 updatedAt: notification.cardUpdatedAt.toISOString(), 192 author: { 193 id: cardAuthorProfile.id, 194 name: cardAuthorProfile.name, 195 handle: cardAuthorProfile.handle, 196 avatarUrl: cardAuthorProfile.avatarUrl, 197 description: cardAuthorProfile.bio, 198 }, 199 note: notification.cardNote, 200 }, 201 createdAt: notification.createdAt.toISOString(), 202 collections, 203 type: notification.type as any, // Cast to NotificationType enum 204 read: notification.read, 205 }; 206 207 notificationItems.push(notificationItem); 208 } catch (error) { 209 // Skip this notification if there's an error processing it 210 console.error('Error processing notification:', error); 211 continue; 212 } 213 } 214 215 const totalPages = Math.ceil(totalCount / limit); 216 217 return ok({ 218 notifications: notificationItems, 219 pagination: { 220 currentPage: page, 221 totalPages, 222 totalCount, 223 hasMore, 224 limit, 225 }, 226 unreadCount, 227 }); 228 } catch (error) { 229 return err(AppError.UnexpectedError.create(error)); 230 } 231 } 232}