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