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