This repository has no description
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}