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