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
11 kB 312 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 { NotificationItem } from '@semble/types'; 9import { CollectionAccessType } from '../../../../cards/domain/Collection'; 10import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 11 12export interface GetMyNotificationsDTO { 13 userId: string; 14 page?: number; 15 limit?: number; 16 unreadOnly?: boolean; 17} 18 19type NotificationItemDTO = NotificationItem; 20 21export interface GetMyNotificationsResponseDTO { 22 notifications: NotificationItemDTO[]; 23 pagination: { 24 currentPage: number; 25 totalPages: number; 26 totalCount: number; 27 hasMore: boolean; 28 limit: number; 29 }; 30 unreadCount: number; 31} 32 33export class ValidationError extends UseCaseError { 34 constructor(message: string) { 35 super(message); 36 } 37} 38 39export class GetMyNotificationsUseCase 40 implements 41 UseCase< 42 GetMyNotificationsDTO, 43 Result< 44 GetMyNotificationsResponseDTO, 45 ValidationError | AppError.UnexpectedError 46 > 47 > 48{ 49 constructor( 50 private notificationRepository: INotificationRepository, 51 private profileService: IProfileService, 52 ) {} 53 54 async execute( 55 request: GetMyNotificationsDTO, 56 ): Promise< 57 Result< 58 GetMyNotificationsResponseDTO, 59 ValidationError | AppError.UnexpectedError 60 > 61 > { 62 try { 63 const userIdResult = CuratorId.create(request.userId); 64 if (userIdResult.isErr()) { 65 return err( 66 new ValidationError(`Invalid user ID: ${userIdResult.error.message}`), 67 ); 68 } 69 70 const page = request.page || 1; 71 const limit = request.limit || 20; 72 73 // Use enriched query to get notifications with card and collection data 74 const result = await this.notificationRepository.findByRecipientEnriched( 75 userIdResult.value, 76 { 77 page, 78 limit, 79 unreadOnly: request.unreadOnly, 80 }, 81 ); 82 83 if (result.isErr()) { 84 return err(new ValidationError(result.error.message)); 85 } 86 87 const { notifications, totalCount, hasMore, unreadCount } = result.value; 88 89 // Collect all unique user IDs for bulk profile fetching 90 const userIds = new Set<string>(); 91 notifications.forEach((notification) => { 92 userIds.add(notification.actorUserId); 93 if (notification.cardAuthorId) { 94 userIds.add(notification.cardAuthorId); 95 } 96 notification.collections?.forEach((collection) => { 97 userIds.add(collection.authorId); 98 }); 99 notification.followCollections?.forEach((collection) => { 100 userIds.add(collection.authorId); 101 }); 102 }); 103 104 // Bulk fetch all profiles using ProfileEnricher 105 const profileEnricher = new ProfileEnricher(this.profileService); 106 const profileMapResult = await profileEnricher.buildProfileMap( 107 Array.from(userIds), 108 request.userId, 109 { 110 skipFailures: true, // Skip profiles that fail to load 111 mapToUser: true, // Use full User DTO with isFollowing 112 }, 113 ); 114 115 if (profileMapResult.isErr()) { 116 return err(AppError.UnexpectedError.create(profileMapResult.error)); 117 } 118 119 const profileMap = profileMapResult.value; 120 121 // Transform enriched notifications to DTOs 122 const notificationItems: NotificationItemDTO[] = []; 123 124 for (const notification of notifications) { 125 try { 126 const actorProfile = profileMap.get(notification.actorUserId); 127 128 if (!actorProfile) { 129 // Skip notifications with missing actor profile 130 continue; 131 } 132 133 // Handle follow notifications (no card data) 134 if (notification.followTargetType) { 135 // Transform follow collections with author profiles 136 const followCollections = (notification.followCollections || []) 137 .map((collection) => { 138 const collectionAuthorProfile = profileMap.get( 139 collection.authorId, 140 ); 141 if (!collectionAuthorProfile) { 142 return null; 143 } 144 145 return { 146 id: collection.id, 147 uri: collection.uri, 148 name: collection.name, 149 author: { 150 id: collectionAuthorProfile.id, 151 name: collectionAuthorProfile.name, 152 handle: collectionAuthorProfile.handle, 153 avatarUrl: collectionAuthorProfile.avatarUrl, 154 bannerUrl: collectionAuthorProfile.bannerUrl, 155 description: collectionAuthorProfile.description, 156 }, 157 description: collection.description, 158 accessType: collection.accessType as CollectionAccessType, 159 cardCount: collection.cardCount, 160 createdAt: collection.createdAt.toISOString(), 161 updatedAt: collection.updatedAt.toISOString(), 162 }; 163 }) 164 .filter( 165 (collection): collection is NonNullable<typeof collection> => 166 collection !== null, 167 ); 168 169 const notificationItem: NotificationItemDTO = { 170 id: notification.id, 171 user: { 172 id: actorProfile.id, 173 name: actorProfile.name, 174 handle: actorProfile.handle, 175 avatarUrl: actorProfile.avatarUrl, 176 bannerUrl: actorProfile.bannerUrl, 177 description: actorProfile.description, 178 isFollowing: actorProfile.isFollowing, 179 }, 180 createdAt: notification.createdAt.toISOString(), 181 type: notification.type as any, 182 read: notification.read, 183 followTargetType: notification.followTargetType, 184 followTargetId: notification.followTargetId, 185 collections: 186 followCollections.length > 0 ? followCollections : undefined, 187 }; 188 189 notificationItems.push(notificationItem); 190 continue; 191 } 192 193 // Handle card-based notifications 194 const cardAuthorProfile = notification.cardAuthorId 195 ? profileMap.get(notification.cardAuthorId) 196 : undefined; 197 198 if (!cardAuthorProfile || !notification.cardId) { 199 // Skip notifications with missing card author profile or card data 200 continue; 201 } 202 203 // Transform collections with author profiles 204 const collections = (notification.collections || []) 205 .map((collection) => { 206 const collectionAuthorProfile = profileMap.get( 207 collection.authorId, 208 ); 209 if (!collectionAuthorProfile) { 210 return null; 211 } 212 213 return { 214 id: collection.id, 215 uri: collection.uri, 216 name: collection.name, 217 author: { 218 id: collectionAuthorProfile.id, 219 name: collectionAuthorProfile.name, 220 handle: collectionAuthorProfile.handle, 221 avatarUrl: collectionAuthorProfile.avatarUrl, 222 bannerUrl: collectionAuthorProfile.bannerUrl, 223 description: collectionAuthorProfile.description, 224 }, 225 description: collection.description, 226 accessType: collection.accessType as CollectionAccessType, 227 cardCount: collection.cardCount, 228 createdAt: collection.createdAt.toISOString(), 229 updatedAt: collection.updatedAt.toISOString(), 230 }; 231 }) 232 .filter( 233 (collection): collection is NonNullable<typeof collection> => 234 collection !== null, 235 ); 236 237 const notificationItem: NotificationItemDTO = { 238 id: notification.id, 239 user: { 240 id: actorProfile.id, 241 name: actorProfile.name, 242 handle: actorProfile.handle, 243 avatarUrl: actorProfile.avatarUrl, 244 bannerUrl: actorProfile.bannerUrl, 245 description: actorProfile.description, 246 isFollowing: actorProfile.isFollowing, 247 }, 248 card: { 249 id: notification.cardId, 250 type: 'URL' as const, 251 url: notification.cardUrl || '', 252 uri: notification.cardUri, 253 cardContent: { 254 url: notification.cardUrl || '', 255 title: notification.cardTitle, 256 description: notification.cardDescription, 257 author: notification.cardAuthor, 258 publishedDate: notification.cardPublishedDate?.toISOString(), 259 siteName: notification.cardSiteName, 260 imageUrl: notification.cardImageUrl, 261 type: notification.cardType, 262 retrievedAt: notification.cardRetrievedAt?.toISOString(), 263 doi: notification.cardDoi, 264 isbn: notification.cardIsbn, 265 }, 266 libraryCount: notification.cardLibraryCount || 0, 267 urlLibraryCount: notification.cardUrlLibraryCount || 0, 268 urlInLibrary: notification.cardUrlInLibrary, 269 createdAt: notification.cardCreatedAt?.toISOString() || '', 270 updatedAt: notification.cardUpdatedAt?.toISOString() || '', 271 author: { 272 id: cardAuthorProfile.id, 273 name: cardAuthorProfile.name, 274 handle: cardAuthorProfile.handle, 275 avatarUrl: cardAuthorProfile.avatarUrl, 276 bannerUrl: cardAuthorProfile.bannerUrl, 277 description: cardAuthorProfile.description, 278 }, 279 note: notification.cardNote, 280 }, 281 createdAt: notification.createdAt.toISOString(), 282 collections, 283 type: notification.type as any, // Cast to NotificationType enum 284 read: notification.read, 285 }; 286 287 notificationItems.push(notificationItem); 288 } catch (error) { 289 // Skip this notification if there's an error processing it 290 console.error('Error processing notification:', error); 291 continue; 292 } 293 } 294 295 const totalPages = Math.ceil(totalCount / limit); 296 297 return ok({ 298 notifications: notificationItems, 299 pagination: { 300 currentPage: page, 301 totalPages, 302 totalCount, 303 hasMore, 304 limit, 305 }, 306 unreadCount, 307 }); 308 } catch (error) { 309 return err(AppError.UnexpectedError.create(error)); 310 } 311 } 312}