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