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