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