This repository has no description
0

Configure Feed

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

semble / src / modules / feeds / application / useCases / queries / GetFollowingFeedUseCase.ts
13 kB 403 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 { IFeedRepository } from '../../../domain/IFeedRepository'; 6import { ActivityId } from '../../../domain/value-objects/ActivityId'; 7import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 8import { 9 ICardQueryRepository, 10 UrlCardView, 11} from '../../../../cards/domain/ICardQueryRepository'; 12import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; 13import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 14import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 15import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 16import { CollectionAccessType } from '../../../../cards/domain/Collection'; 17 18export interface GetFollowingFeedQuery { 19 callingUserId: string; 20 page?: number; 21 limit?: number; 22 beforeActivityId?: string; // For cursor-based pagination 23 urlType?: string; // Filter by URL type 24 source?: ActivitySource; // Filter by activity source 25} 26 27// Use the shared API type directly 28export type GetFollowingFeedResult = GetGlobalFeedResponse; 29 30export class ValidationError extends UseCaseError { 31 constructor(message: string) { 32 super(message); 33 } 34} 35 36export class GetFollowingFeedUseCase 37 implements 38 UseCase< 39 GetFollowingFeedQuery, 40 Result<GetFollowingFeedResult, ValidationError | AppError.UnexpectedError> 41 > 42{ 43 constructor( 44 private feedRepository: IFeedRepository, 45 private profileService: IProfileService, 46 private cardQueryRepository: ICardQueryRepository, 47 private collectionRepository: ICollectionRepository, 48 ) {} 49 50 async execute( 51 query: GetFollowingFeedQuery, 52 ): Promise< 53 Result<GetFollowingFeedResult, ValidationError | AppError.UnexpectedError> 54 > { 55 try { 56 // Set defaults and validate 57 const page = query.page || 1; 58 const limit = Math.min(query.limit || 20, 100); // Cap at 100 59 60 let beforeActivityId: ActivityId | undefined; 61 if (query.beforeActivityId) { 62 const activityIdResult = ActivityId.createFromString( 63 query.beforeActivityId, 64 ); 65 if (activityIdResult.isErr()) { 66 return err( 67 new ValidationError( 68 `Invalid beforeActivityId: ${activityIdResult.error.message}`, 69 ), 70 ); 71 } 72 beforeActivityId = activityIdResult.value; 73 } 74 75 // Parse urlType if provided 76 let urlType: UrlType | undefined; 77 if (query.urlType) { 78 urlType = query.urlType as UrlType; 79 } 80 81 // Fetch activities from repository for the user's following feed 82 const feedResult = await this.feedRepository.getFollowingFeed( 83 query.callingUserId, 84 { 85 page, 86 limit, 87 beforeActivityId, 88 urlType, 89 source: query.source, 90 }, 91 ); 92 93 if (feedResult.isErr()) { 94 return err(AppError.UnexpectedError.create(feedResult.error)); 95 } 96 97 const feed = feedResult.value; 98 99 // Get unique actor IDs for profile enrichment 100 const actorIds = [ 101 ...new Set(feed.activities.map((activity) => activity.actorId.value)), 102 ]; 103 104 // Fetch profiles for all actors 105 const actorProfiles = new Map< 106 string, 107 { 108 id: string; 109 name: string; 110 handle: string; 111 avatarUrl?: string; 112 description?: string; 113 } 114 >(); 115 const profileResults = await Promise.all( 116 actorIds.map((actorId) => this.profileService.getProfile(actorId)), 117 ); 118 119 profileResults.forEach((profileResult, idx) => { 120 const actorId = actorIds[idx]; 121 if (!actorId) { 122 return; 123 } 124 if (profileResult.isOk()) { 125 const profile = profileResult.value; 126 actorProfiles.set(actorId, { 127 id: profile.id, 128 name: profile.name, 129 handle: profile.handle, 130 avatarUrl: profile.avatarUrl, 131 description: profile.bio, 132 }); 133 } else { 134 // If profile fetch fails, create a fallback 135 actorProfiles.set(actorId, { 136 id: actorId, 137 name: 'Unknown User', 138 handle: actorId, 139 }); 140 } 141 }); 142 143 // Get unique card IDs for hydration 144 const cardIds = [ 145 ...new Set( 146 feed.activities 147 .filter((activity) => activity.cardCollected) 148 .map((activity) => activity.metadata.cardId), 149 ), 150 ]; 151 152 // Hydrate card data and fetch card authors 153 const cardDataMap = new Map<string, UrlCardView>(); 154 const cardViews = await Promise.all( 155 cardIds.map((cardId) => 156 this.cardQueryRepository.getUrlCardView(cardId, query.callingUserId), 157 ), 158 ); 159 cardIds.forEach((cardId, idx) => { 160 const cardView = cardViews[idx]; 161 if (cardView) { 162 cardDataMap.set(cardId, cardView); 163 } 164 }); 165 166 // Get unique card author IDs 167 const cardAuthorIds = [ 168 ...new Set( 169 Array.from(cardDataMap.values()).map((card) => card.authorId), 170 ), 171 ]; 172 173 // Fetch card author profiles 174 const cardAuthorProfiles = new Map< 175 string, 176 { 177 id: string; 178 name: string; 179 handle: string; 180 avatarUrl?: string; 181 description?: string; 182 } 183 >(); 184 const cardAuthorResults = await Promise.all( 185 cardAuthorIds.map((authorId) => 186 this.profileService.getProfile(authorId, query.callingUserId), 187 ), 188 ); 189 190 cardAuthorResults.forEach((profileResult, idx) => { 191 const authorId = cardAuthorIds[idx]; 192 if (!authorId) { 193 return; 194 } 195 if (profileResult.isOk()) { 196 const profile = profileResult.value; 197 cardAuthorProfiles.set(authorId, { 198 id: profile.id, 199 name: profile.name, 200 handle: profile.handle, 201 avatarUrl: profile.avatarUrl, 202 description: profile.bio, 203 }); 204 } 205 }); 206 207 // Get collection data for activities that have collections 208 const collectionIds = [ 209 ...new Set( 210 feed.activities 211 .filter( 212 (activity) => 213 activity.cardCollected && activity.metadata.collectionIds, 214 ) 215 .flatMap((activity) => activity.metadata.collectionIds || []), 216 ), 217 ]; 218 219 const collectionDataMap = new Map< 220 string, 221 { 222 id: string; 223 uri?: string; 224 name: string; 225 description?: string; 226 accessType: CollectionAccessType; 227 author: { 228 id: string; 229 name: string; 230 handle: string; 231 avatarUrl?: string; 232 description?: string; 233 }; 234 cardCount: number; 235 createdAt: string; 236 updatedAt: string; 237 cardIds: Set<string>; // Track which cards are in this collection 238 } 239 >(); 240 // Fetch all collections in parallel using Promise.all 241 const collectionResults = await Promise.all( 242 collectionIds.map(async (collectionId) => { 243 const collectionIdResult = 244 CollectionId.createFromString(collectionId); 245 if (collectionIdResult.isErr()) { 246 return null; // Skip invalid collection IDs 247 } 248 const collectionResult = await this.collectionRepository.findById( 249 collectionIdResult.value, 250 ); 251 if (collectionResult.isErr() || !collectionResult.value) { 252 return null; 253 } 254 255 const collection = collectionResult.value; 256 257 // Get author profile 258 const authorProfileResult = await this.profileService.getProfile( 259 collection.authorId.value, 260 query.callingUserId, 261 ); 262 if (authorProfileResult.isErr()) { 263 return null; 264 } 265 266 const authorProfile = authorProfileResult.value; 267 const uri = collection.publishedRecordId?.uri; 268 269 // Get the card IDs in this collection 270 const cardIds = new Set( 271 collection.cardIds.map((cardId) => cardId.getStringValue()), 272 ); 273 274 return { 275 id: collection.collectionId.getStringValue(), 276 uri, 277 name: collection.name.toString(), 278 description: collection.description?.toString(), 279 accessType: collection.accessType, 280 author: { 281 id: authorProfile.id, 282 name: authorProfile.name, 283 handle: authorProfile.handle, 284 avatarUrl: authorProfile.avatarUrl, 285 description: authorProfile.bio, 286 }, 287 cardCount: collection.cardCount, 288 createdAt: collection.createdAt.toISOString(), 289 updatedAt: collection.updatedAt.toISOString(), 290 cardIds, 291 collectionId, 292 }; 293 }), 294 ); 295 296 collectionResults.forEach((result) => { 297 if (result) { 298 collectionDataMap.set(result.collectionId, { 299 id: result.id, 300 uri: result.uri, 301 name: result.name, 302 description: result.description, 303 accessType: result.accessType, 304 author: result.author, 305 cardCount: result.cardCount, 306 createdAt: result.createdAt, 307 updatedAt: result.updatedAt, 308 cardIds: result.cardIds, 309 }); 310 } 311 }); 312 313 // Transform activities to FeedItem 314 const feedItems: FeedItem[] = []; 315 for (const activity of feed.activities) { 316 if (!activity.cardCollected) { 317 continue; // Skip non-card-collected activities 318 } 319 320 const actor = actorProfiles.get(activity.actorId.value); 321 const cardView = cardDataMap.get(activity.metadata.cardId); 322 323 if (!actor || !cardView) { 324 continue; // Skip if we can't hydrate required data 325 } 326 327 // Get card author 328 const cardAuthor = cardAuthorProfiles.get(cardView.authorId); 329 if (!cardAuthor) { 330 continue; // Skip if we can't get card author 331 } 332 333 // Transform UrlCardView to UrlCardDTO 334 const cardDTO = { 335 id: cardView.id, 336 type: 'URL' as const, 337 url: cardView.url, 338 uri: cardView.uri, 339 cardContent: { 340 url: cardView.cardContent.url, 341 title: cardView.cardContent.title, 342 description: cardView.cardContent.description, 343 author: cardView.cardContent.author, 344 publishedDate: cardView.cardContent.publishedDate?.toISOString(), 345 siteName: cardView.cardContent.siteName, 346 imageUrl: cardView.cardContent.imageUrl, 347 type: cardView.cardContent.type, 348 retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), 349 doi: cardView.cardContent.doi, 350 isbn: cardView.cardContent.isbn, 351 }, 352 libraryCount: cardView.libraryCount, 353 urlLibraryCount: cardView.urlLibraryCount, 354 urlInLibrary: cardView.urlInLibrary, 355 createdAt: cardView.createdAt.toISOString(), 356 updatedAt: cardView.updatedAt.toISOString(), 357 author: cardAuthor, 358 note: cardView.note, 359 }; 360 361 const collections = (activity.metadata.collectionIds || []) 362 .map((collectionId) => collectionDataMap.get(collectionId)) 363 .filter((collection) => !!collection) 364 .filter((collection) => 365 collection.cardIds.has(activity.metadata.cardId), 366 ) 367 .map((collection) => ({ 368 id: collection.id, 369 uri: collection.uri, 370 name: collection.name, 371 description: collection.description, 372 accessType: collection.accessType, 373 author: collection.author, 374 cardCount: collection.cardCount, 375 createdAt: collection.createdAt, 376 updatedAt: collection.updatedAt, 377 })); 378 379 feedItems.push({ 380 id: activity.activityId.getStringValue(), 381 user: actor, 382 card: cardDTO, 383 createdAt: activity.createdAt, 384 collections, 385 }); 386 } 387 388 return ok({ 389 activities: feedItems, 390 pagination: { 391 currentPage: page, 392 totalPages: Math.ceil(feed.totalCount / limit), 393 totalCount: feed.totalCount, 394 hasMore: feed.hasMore, 395 limit, 396 nextCursor: feed.nextCursor?.getStringValue(), 397 }, 398 }); 399 } catch (error) { 400 return err(AppError.UnexpectedError.create(error)); 401 } 402 } 403}