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