This repository has no description
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}