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
23 kB 662 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 { IConnectionRepository } from 'src/modules/cards/domain/IConnectionRepository'; 14import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 15import { ConnectionId } from 'src/modules/cards/domain/value-objects/ConnectionId'; 16import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 17import { 18 GetGlobalFeedResponse, 19 FeedItem, 20 ActivitySource, 21 CardCollectedFeedItem, 22 ConnectionCreatedFeedItem, 23} from '@semble/types'; 24import { CollectionAccessType } from '../../../../cards/domain/Collection'; 25import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 26import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 27import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 28import { 29 CardCollectedMetadata, 30 ConnectionCreatedMetadata, 31} from '../../../domain/FeedActivity'; 32import { ActivityType as ActivityTypeEnum } from '@semble/types'; 33 34export interface GetGlobalFeedQuery { 35 callingUserId?: string; 36 page?: number; 37 limit?: number; 38 beforeActivityId?: string; // For cursor-based pagination 39 urlType?: string; // Filter by URL type 40 source?: ActivitySource; // Filter by activity source 41 activityTypes?: string[]; // Filter by activity types 42 includeKnownBots?: boolean; // Include known bots in feed (default: false) 43} 44 45// Use the shared API type directly 46export type GetGlobalFeedResult = GetGlobalFeedResponse; 47 48export class ValidationError extends UseCaseError { 49 constructor(message: string) { 50 super(message); 51 } 52} 53 54export class GetGlobalFeedUseCase 55 implements 56 UseCase< 57 GetGlobalFeedQuery, 58 Result<GetGlobalFeedResult, ValidationError | AppError.UnexpectedError> 59 > 60{ 61 constructor( 62 private feedRepository: IFeedRepository, 63 private profileService: IProfileService, 64 private cardQueryRepository: ICardQueryRepository, 65 private collectionRepository: ICollectionRepository, 66 private connectionRepository: IConnectionRepository, 67 private followsRepository: IFollowsRepository, 68 ) {} 69 70 async execute( 71 query: GetGlobalFeedQuery, 72 ): Promise< 73 Result<GetGlobalFeedResult, ValidationError | AppError.UnexpectedError> 74 > { 75 try { 76 // Set defaults and validate 77 const page = query.page || 1; 78 const limit = Math.min(query.limit || 20, 100); // Cap at 100 79 80 let beforeActivityId: ActivityId | undefined; 81 if (query.beforeActivityId) { 82 const activityIdResult = ActivityId.createFromString( 83 query.beforeActivityId, 84 ); 85 if (activityIdResult.isErr()) { 86 return err( 87 new ValidationError( 88 `Invalid beforeActivityId: ${activityIdResult.error.message}`, 89 ), 90 ); 91 } 92 beforeActivityId = activityIdResult.value; 93 } 94 95 // Parse urlType if provided 96 let urlType: UrlType | undefined; 97 if (query.urlType) { 98 urlType = query.urlType as UrlType; 99 } 100 101 // Parse activityTypes if provided 102 let activityTypes: ActivityTypeEnum[] | undefined; 103 if (query.activityTypes && query.activityTypes.length > 0) { 104 activityTypes = query.activityTypes as ActivityTypeEnum[]; 105 } 106 107 // Fetch activities from repository 108 const feedResult = await this.feedRepository.getGlobalFeed({ 109 page, 110 limit, 111 beforeActivityId, 112 urlType, 113 source: query.source, 114 activityTypes, 115 includeKnownBots: query.includeKnownBots, 116 }); 117 118 if (feedResult.isErr()) { 119 return err(AppError.UnexpectedError.create(feedResult.error)); 120 } 121 122 const feed = feedResult.value; 123 124 // Get unique actor IDs for profile enrichment 125 const actorIds = [ 126 ...new Set(feed.activities.map((activity) => activity.actorId.value)), 127 ]; 128 129 // Fetch profiles for all actors using ProfileEnricher 130 const profileEnricher = new ProfileEnricher(this.profileService); 131 const actorProfilesResult = await profileEnricher.buildProfileMap( 132 actorIds, 133 undefined, // No calling user for actor profiles 134 { 135 skipFailures: true, 136 includeFallback: true, 137 mapToUser: false, // Use inline profile (without isFollowing) 138 }, 139 ); 140 141 if (actorProfilesResult.isErr()) { 142 return err(AppError.UnexpectedError.create(actorProfilesResult.error)); 143 } 144 145 const actorProfiles = actorProfilesResult.value; 146 147 // Get unique card IDs for hydration 148 const cardIds = [ 149 ...new Set( 150 feed.activities 151 .filter((activity) => activity.cardCollected) 152 .map( 153 (activity) => (activity.metadata as CardCollectedMetadata).cardId, 154 ), 155 ), 156 ]; 157 158 // Batch fetch card data 159 const cardDataMap = await this.cardQueryRepository.getBatchUrlCardViews( 160 cardIds, 161 query.callingUserId, 162 ); 163 164 // Get unique card author IDs 165 const cardAuthorIds = [ 166 ...new Set( 167 Array.from(cardDataMap.values()).map((card) => card.authorId), 168 ), 169 ]; 170 171 // Fetch card author profiles using ProfileEnricher 172 const cardAuthorProfilesResult = await profileEnricher.buildProfileMap( 173 cardAuthorIds, 174 query.callingUserId, 175 { 176 skipFailures: true, // Skip cards with failed author profiles 177 mapToUser: false, // Use inline profile (without isFollowing) 178 }, 179 ); 180 181 if (cardAuthorProfilesResult.isErr()) { 182 return err( 183 AppError.UnexpectedError.create(cardAuthorProfilesResult.error), 184 ); 185 } 186 187 const cardAuthorProfiles = cardAuthorProfilesResult.value; 188 189 // Get collection data for activities that have collections 190 const collectionIds = [ 191 ...new Set( 192 feed.activities 193 .filter( 194 (activity) => 195 activity.cardCollected && 196 (activity.metadata as CardCollectedMetadata).collectionIds, 197 ) 198 .flatMap( 199 (activity) => 200 (activity.metadata as CardCollectedMetadata).collectionIds || 201 [], 202 ), 203 ), 204 ]; 205 206 // Batch fetch all collections 207 const collectionIdObjects: CollectionId[] = []; 208 const collectionIdMap = new Map<string, string>(); // Map UUID string to original string 209 210 for (const collectionId of collectionIds) { 211 const collectionIdResult = CollectionId.createFromString(collectionId); 212 if (collectionIdResult.isOk()) { 213 collectionIdObjects.push(collectionIdResult.value); 214 collectionIdMap.set( 215 collectionIdResult.value.getStringValue(), 216 collectionId, 217 ); 218 } 219 } 220 221 const collectionsResult = 222 await this.collectionRepository.findByIds(collectionIdObjects); 223 224 if (collectionsResult.isErr()) { 225 return err(AppError.UnexpectedError.create(collectionsResult.error)); 226 } 227 228 const validCollections = collectionsResult.value.map((collection) => { 229 const uri = collection.publishedRecordId?.uri; 230 const cardIds = new Set( 231 collection.cardIds.map((cardId) => cardId.getStringValue()), 232 ); 233 234 return { 235 id: collection.collectionId.getStringValue(), 236 uri, 237 name: collection.name.toString(), 238 description: collection.description?.toString(), 239 accessType: collection.accessType, 240 authorId: collection.authorId.value, 241 cardCount: collection.cardCount, 242 createdAt: collection.createdAt.toISOString(), 243 updatedAt: collection.updatedAt.toISOString(), 244 cardIds, 245 collectionId: collectionIdMap.get( 246 collection.collectionId.getStringValue(), 247 )!, 248 }; 249 }); 250 251 // Get unique collection author IDs 252 const collectionAuthorIds = [ 253 ...new Set(validCollections.map((c) => c.authorId)), 254 ]; 255 256 // Batch fetch collection author profiles using ProfileEnricher 257 const collectionAuthorProfilesResult = 258 await profileEnricher.buildProfileMap( 259 collectionAuthorIds, 260 query.callingUserId, 261 { 262 skipFailures: true, // Skip collections with failed author profiles 263 mapToUser: false, // Use inline profile (without isFollowing) 264 }, 265 ); 266 267 if (collectionAuthorProfilesResult.isErr()) { 268 return err( 269 AppError.UnexpectedError.create(collectionAuthorProfilesResult.error), 270 ); 271 } 272 273 const collectionAuthorProfiles = collectionAuthorProfilesResult.value; 274 275 // Build collection data map with enriched author data 276 const collectionDataMap = new Map< 277 string, 278 { 279 id: string; 280 uri?: string; 281 name: string; 282 description?: string; 283 accessType: CollectionAccessType; 284 author: { 285 id: string; 286 name: string; 287 handle: string; 288 avatarUrl?: string; 289 bannerUrl?: string; 290 description?: string; 291 }; 292 cardCount: number; 293 createdAt: string; 294 updatedAt: string; 295 cardIds: Set<string>; // Track which cards are in this collection 296 } 297 >(); 298 299 validCollections.forEach((result) => { 300 const author = collectionAuthorProfiles.get(result.authorId); 301 if (!author) { 302 return; // Skip collections with missing author profiles 303 } 304 305 collectionDataMap.set(result.collectionId, { 306 id: result.id, 307 uri: result.uri, 308 name: result.name, 309 description: result.description, 310 accessType: result.accessType, 311 author: { 312 id: author.id, 313 name: author.name, 314 handle: author.handle, 315 avatarUrl: author.avatarUrl, 316 bannerUrl: author.bannerUrl, 317 description: author.description, 318 }, 319 cardCount: result.cardCount, 320 createdAt: result.createdAt, 321 updatedAt: result.updatedAt, 322 cardIds: result.cardIds, 323 }); 324 }); 325 326 // Add follow status for collections if callingUserId is provided 327 let collectionFollowStatusMap = new Map<string, boolean>(); 328 if (query.callingUserId && collectionIds.length > 0) { 329 const followMapResult = 330 await this.followsRepository.checkFollowingMultiple( 331 query.callingUserId, 332 collectionIds, 333 FollowTargetType.COLLECTION, 334 ); 335 336 if (followMapResult.isOk()) { 337 collectionFollowStatusMap = followMapResult.value; 338 } 339 } 340 341 // Batch fetch connection data for CONNECTION_CREATED activities 342 const connectionActivities = feed.activities.filter( 343 (activity) => activity.connectionCreated, 344 ); 345 346 let connectionDataMap = new Map< 347 string, 348 { 349 id: string; 350 type?: string; 351 note?: string; 352 createdAt: string; 353 updatedAt: string; 354 curatorId: string; 355 sourceUrl: string; 356 sourceUrlMetadata?: any; 357 targetUrl: string; 358 targetUrlMetadata?: any; 359 } 360 >(); 361 let connectionCuratorProfiles = new Map<string, any>(); 362 let connectionUrlStatsMap = new Map< 363 string, 364 { 365 urlLibraryCount: number; 366 urlInLibrary?: boolean; 367 urlConnectionCount?: number; 368 urlIsConnected?: boolean; 369 } 370 >(); 371 372 if (connectionActivities.length > 0) { 373 // Get unique connection IDs 374 const connectionIds = [ 375 ...new Set( 376 connectionActivities.map( 377 (activity) => 378 (activity.metadata as ConnectionCreatedMetadata).connectionId, 379 ), 380 ), 381 ]; 382 383 // Batch fetch connections 384 const connectionIdObjects: ConnectionId[] = []; 385 for (const connectionId of connectionIds) { 386 const connectionIdResult = 387 ConnectionId.createFromString(connectionId); 388 if (connectionIdResult.isOk()) { 389 connectionIdObjects.push(connectionIdResult.value); 390 } 391 } 392 393 const connectionsResult = 394 await this.connectionRepository.findByIds(connectionIdObjects); 395 396 if (connectionsResult.isErr()) { 397 return err(AppError.UnexpectedError.create(connectionsResult.error)); 398 } 399 400 const connections = connectionsResult.value; 401 402 // Build connection data map 403 for (const connection of connections) { 404 const sourceUrl = connection.source.url?.value; 405 const targetUrl = connection.target.url?.value; 406 407 // Only include connections where both source and target are URLs 408 if (sourceUrl && targetUrl) { 409 connectionDataMap.set(connection.connectionId.getStringValue(), { 410 id: connection.connectionId.getStringValue(), 411 type: connection.type?.value, 412 note: connection.note?.value, 413 createdAt: connection.createdAt.toISOString(), 414 updatedAt: connection.updatedAt.toISOString(), 415 curatorId: connection.curatorId.value, 416 sourceUrl, 417 sourceUrlMetadata: connection.sourceUrlMetadata, 418 targetUrl, 419 targetUrlMetadata: connection.targetUrlMetadata, 420 }); 421 } 422 } 423 424 // Get unique curator IDs for connections 425 const connectionCuratorIds = [ 426 ...new Set( 427 Array.from(connectionDataMap.values()).map((c) => c.curatorId), 428 ), 429 ]; 430 431 // Batch fetch curator profiles 432 const connectionCuratorProfilesResult = 433 await profileEnricher.buildProfileMap( 434 connectionCuratorIds, 435 query.callingUserId, 436 { 437 skipFailures: true, 438 mapToUser: false, 439 }, 440 ); 441 442 if (connectionCuratorProfilesResult.isErr()) { 443 return err( 444 AppError.UnexpectedError.create( 445 connectionCuratorProfilesResult.error, 446 ), 447 ); 448 } 449 450 connectionCuratorProfiles = connectionCuratorProfilesResult.value; 451 452 // Fetch URL library info for connection URLs 453 const connectionUrls = Array.from( 454 new Set([ 455 ...Array.from(connectionDataMap.values()).map((c) => c.sourceUrl), 456 ...Array.from(connectionDataMap.values()).map((c) => c.targetUrl), 457 ]), 458 ); 459 460 const connectionUrlLibraryInfoMap = 461 await this.cardQueryRepository.getBatchUrlLibraryInfo( 462 connectionUrls, 463 query.callingUserId, 464 ); 465 466 // Build a map of URL to stats for easy lookup 467 connectionUrls.forEach((url) => { 468 const urlInfo = connectionUrlLibraryInfoMap.get(url); 469 if (urlInfo) { 470 connectionUrlStatsMap.set(url, { 471 urlLibraryCount: urlInfo.urlLibraryCount, 472 urlInLibrary: urlInfo.urlInLibrary, 473 urlConnectionCount: urlInfo.urlConnectionCount, 474 urlIsConnected: urlInfo.urlIsConnected, 475 }); 476 } 477 }); 478 } 479 480 // Transform activities to FeedItem in chronological order 481 const feedItems: FeedItem[] = []; 482 for (const activity of feed.activities) { 483 const actor = actorProfiles.get(activity.actorId.value); 484 if (!actor) { 485 continue; // Skip if we can't get actor 486 } 487 488 if (activity.cardCollected) { 489 // Handle CARD_COLLECTED activity 490 const metadata = activity.metadata as CardCollectedMetadata; 491 const cardView = cardDataMap.get(metadata.cardId); 492 493 if (!cardView) { 494 continue; // Skip if we can't hydrate required data 495 } 496 497 // Get card author 498 const cardAuthor = cardAuthorProfiles.get(cardView.authorId); 499 if (!cardAuthor) { 500 continue; // Skip if we can't get card author 501 } 502 503 // Transform UrlCardView to UrlCardDTO 504 const cardDTO = { 505 id: cardView.id, 506 type: 'URL' as const, 507 url: cardView.url, 508 uri: cardView.uri, 509 cardContent: { 510 url: cardView.cardContent.url, 511 title: cardView.cardContent.title, 512 description: cardView.cardContent.description, 513 author: cardView.cardContent.author, 514 publishedDate: cardView.cardContent.publishedDate?.toISOString(), 515 siteName: cardView.cardContent.siteName, 516 imageUrl: cardView.cardContent.imageUrl, 517 type: cardView.cardContent.type, 518 retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), 519 doi: cardView.cardContent.doi, 520 isbn: cardView.cardContent.isbn, 521 }, 522 libraryCount: cardView.libraryCount, 523 urlLibraryCount: cardView.urlLibraryCount, 524 urlInLibrary: cardView.urlInLibrary, 525 urlConnectionCount: cardView.urlConnectionCount, 526 urlIsConnected: cardView.urlIsConnected, 527 createdAt: cardView.createdAt.toISOString(), 528 updatedAt: cardView.updatedAt.toISOString(), 529 author: cardAuthor, 530 note: cardView.note, 531 }; 532 533 const collections = (metadata.collectionIds || []) 534 .map((collectionId) => { 535 const collection = collectionDataMap.get(collectionId); 536 if (!collection) return null; 537 538 return { 539 collection, 540 collectionId, 541 }; 542 }) 543 .filter((item) => !!item) 544 .filter((item) => item.collection.cardIds.has(metadata.cardId)) 545 .map((item) => ({ 546 id: item.collection.id, 547 uri: item.collection.uri, 548 name: item.collection.name, 549 description: item.collection.description, 550 accessType: item.collection.accessType, 551 author: item.collection.author, 552 cardCount: item.collection.cardCount, 553 createdAt: item.collection.createdAt, 554 updatedAt: item.collection.updatedAt, 555 isFollowing: collectionFollowStatusMap.get(item.collectionId), 556 })); 557 558 feedItems.push({ 559 id: activity.activityId.getStringValue(), 560 activityType: ActivityTypeEnum.CARD_COLLECTED, 561 user: actor, 562 card: cardDTO, 563 createdAt: activity.createdAt, 564 collections, 565 } as CardCollectedFeedItem); 566 } else if (activity.connectionCreated) { 567 // Handle CONNECTION_CREATED activity 568 const metadata = activity.metadata as ConnectionCreatedMetadata; 569 const connectionData = connectionDataMap.get(metadata.connectionId); 570 571 if (!connectionData) { 572 continue; // Skip if we can't hydrate required data 573 } 574 575 const curator = connectionCuratorProfiles.get( 576 connectionData.curatorId, 577 ); 578 if (!curator) { 579 continue; // Skip if we can't get curator profile 580 } 581 582 // Build UrlView for source and target 583 // Extract metadata props to avoid the value object wrapper 584 const sourceUrlStats = connectionUrlStatsMap.get( 585 connectionData.sourceUrl, 586 ) || { 587 urlLibraryCount: 0, 588 urlInLibrary: undefined, 589 urlConnectionCount: undefined, 590 urlIsConnected: undefined, 591 }; 592 593 const targetUrlStats = connectionUrlStatsMap.get( 594 connectionData.targetUrl, 595 ) || { 596 urlLibraryCount: 0, 597 urlInLibrary: undefined, 598 urlConnectionCount: undefined, 599 urlIsConnected: undefined, 600 }; 601 602 const sourceUrlView = { 603 url: connectionData.sourceUrl, 604 metadata: connectionData.sourceUrlMetadata?.props || 605 connectionData.sourceUrlMetadata || { 606 url: connectionData.sourceUrl, 607 }, 608 urlLibraryCount: sourceUrlStats.urlLibraryCount, 609 urlInLibrary: sourceUrlStats.urlInLibrary, 610 urlConnectionCount: sourceUrlStats.urlConnectionCount, 611 urlIsConnected: sourceUrlStats.urlIsConnected, 612 }; 613 614 const targetUrlView = { 615 url: connectionData.targetUrl, 616 metadata: connectionData.targetUrlMetadata?.props || 617 connectionData.targetUrlMetadata || { 618 url: connectionData.targetUrl, 619 }, 620 urlLibraryCount: targetUrlStats.urlLibraryCount, 621 urlInLibrary: targetUrlStats.urlInLibrary, 622 urlConnectionCount: targetUrlStats.urlConnectionCount, 623 urlIsConnected: targetUrlStats.urlIsConnected, 624 }; 625 626 feedItems.push({ 627 id: activity.activityId.getStringValue(), 628 activityType: 'CONNECTION_CREATED' as const, 629 user: actor, 630 createdAt: activity.createdAt, 631 connection: { 632 connection: { 633 id: connectionData.id, 634 type: connectionData.type, 635 note: connectionData.note, 636 createdAt: connectionData.createdAt, 637 updatedAt: connectionData.updatedAt, 638 curator, 639 }, 640 source: sourceUrlView, 641 target: targetUrlView, 642 }, 643 } as ConnectionCreatedFeedItem); 644 } 645 } 646 647 return ok({ 648 activities: feedItems, 649 pagination: { 650 currentPage: page, 651 totalPages: Math.ceil(feed.totalCount / limit), 652 totalCount: feed.totalCount, 653 hasMore: feed.hasMore, 654 limit, 655 nextCursor: feed.nextCursor?.getStringValue(), 656 }, 657 }); 658 } catch (error) { 659 return err(AppError.UnexpectedError.create(error)); 660 } 661 } 662}