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