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