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