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