A social knowledge tool for researchers built on ATProto
1import {
2 ICardQueryRepository,
3 CardQueryOptions,
4 UrlCardQueryResultDTO,
5 CollectionCardQueryResultDTO,
6 UrlCardViewDTO,
7 UrlCardView,
8 PaginatedQueryResult,
9 CardSortField,
10 SortOrder,
11 LibraryForUrlDTO,
12 NoteCardForUrlRawDTO,
13} from '../../domain/ICardQueryRepository';
14import { CardTypeEnum } from '../../domain/value-objects/CardType';
15import { InMemoryCardRepository } from './InMemoryCardRepository';
16import { InMemoryCollectionRepository } from './InMemoryCollectionRepository';
17import { Card } from '../../domain/Card';
18import { CollectionId } from '../../domain/value-objects/CollectionId';
19import { CuratorId } from '../../domain/value-objects/CuratorId';
20
21export class InMemoryCardQueryRepository implements ICardQueryRepository {
22 constructor(
23 private cardRepository: InMemoryCardRepository,
24 private collectionRepository: InMemoryCollectionRepository,
25 ) {}
26
27 async getUrlCardsOfUser(
28 userId: string,
29 options: CardQueryOptions,
30 callingUserId?: string,
31 ): Promise<PaginatedQueryResult<UrlCardQueryResultDTO>> {
32 try {
33 // Get all cards and filter by user's library membership
34 const allCards = this.cardRepository.getAllCards();
35 const userCards = allCards
36 .filter(
37 (card) =>
38 card.isUrlCard &&
39 card.isInLibrary(CuratorId.create(userId).unwrap()),
40 )
41 .map((card) => this.cardToUrlCardQueryResult(card, callingUserId));
42
43 // Sort cards
44 const sortedCards = this.sortCards(
45 userCards,
46 options.sortBy,
47 options.sortOrder,
48 );
49
50 // Apply pagination
51 const startIndex = (options.page - 1) * options.limit;
52 const endIndex = startIndex + options.limit;
53 const paginatedCards = sortedCards.slice(startIndex, endIndex);
54
55 return {
56 items: paginatedCards,
57 totalCount: userCards.length,
58 hasMore: endIndex < userCards.length,
59 };
60 } catch (error) {
61 throw new Error(
62 `Failed to query URL cards: ${error instanceof Error ? error.message : String(error)}`,
63 );
64 }
65 }
66
67 private sortCards(
68 cards: UrlCardQueryResultDTO[],
69 sortBy: CardSortField,
70 sortOrder: SortOrder,
71 ): UrlCardQueryResultDTO[] {
72 const sorted = [...cards].sort((a, b) => {
73 let comparison = 0;
74
75 switch (sortBy) {
76 case CardSortField.CREATED_AT:
77 comparison = a.createdAt.getTime() - b.createdAt.getTime();
78 break;
79 case CardSortField.UPDATED_AT:
80 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
81 break;
82 case CardSortField.LIBRARY_COUNT:
83 comparison = a.libraryCount - b.libraryCount;
84 break;
85 default:
86 comparison = 0;
87 }
88
89 return sortOrder === SortOrder.DESC ? -comparison : comparison;
90 });
91
92 return sorted;
93 }
94
95 private cardToUrlCardQueryResult(
96 card: Card,
97 callingUserId?: string,
98 ): UrlCardQueryResultDTO {
99 if (!card.isUrlCard || !card.content.urlContent) {
100 throw new Error('Card is not a URL card');
101 }
102
103 // Find collections this card belongs to by querying the collection repository
104 const allCollections = this.collectionRepository.getAllCollections();
105 const collections: { id: string; name: string; authorId: string }[] = [];
106
107 for (const collection of allCollections) {
108 if (
109 collection.cardIds.some(
110 (cardId) => cardId.getStringValue() === card.cardId.getStringValue(),
111 )
112 ) {
113 collections.push({
114 id: collection.collectionId.getStringValue(),
115 name: collection.name.value,
116 authorId: collection.authorId.value,
117 });
118 }
119 }
120
121 // Find note cards with matching URL
122 const allCards = this.cardRepository.getAllCards();
123 const noteCard = allCards.find(
124 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value,
125 );
126
127 const note = noteCard
128 ? {
129 id: noteCard.cardId.getStringValue(),
130 text: noteCard.content.noteContent?.text || '',
131 }
132 : undefined;
133
134 // Compute urlInLibrary if callingUserId is provided
135 const urlInLibrary = callingUserId
136 ? this.isUrlInUserLibrary(
137 card.content.urlContent.url.value,
138 callingUserId,
139 )
140 : undefined;
141
142 return {
143 id: card.cardId.getStringValue(),
144 type: CardTypeEnum.URL,
145 url: card.content.urlContent.url.value,
146 cardContent: {
147 url: card.content.urlContent.url.value,
148 title: card.content.urlContent.metadata?.title,
149 description: card.content.urlContent.metadata?.description,
150 author: card.content.urlContent.metadata?.author,
151 thumbnailUrl: card.content.urlContent.metadata?.imageUrl,
152 },
153 libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()),
154 urlLibraryCount: this.getUrlLibraryCount(
155 card.content.urlContent.url.value,
156 ),
157 urlInLibrary,
158 createdAt: card.createdAt,
159 updatedAt: card.updatedAt,
160 authorId: card.curatorId.value,
161 collections,
162 note,
163 };
164 }
165
166 private getLibraryCountForCard(cardId: string): number {
167 const card = this.cardRepository.getStoredCard({
168 getStringValue: () => cardId,
169 } as any);
170 return card ? card.libraryMembershipCount : 0;
171 }
172
173 private getUrlLibraryCount(url: string): number {
174 // Get all URL cards with this URL and count unique library memberships
175 const allCards = this.cardRepository.getAllCards();
176 const urlCards = allCards.filter(
177 (card) => card.isUrlCard && card.url?.value === url,
178 );
179
180 // Get all unique user IDs who have any card with this URL
181 const uniqueUserIds = new Set<string>();
182 for (const card of urlCards) {
183 for (const membership of card.libraryMemberships) {
184 uniqueUserIds.add(membership.curatorId.value);
185 }
186 }
187
188 return uniqueUserIds.size;
189 }
190
191 private isUrlInUserLibrary(url: string, userId: string): boolean {
192 // Check if the user has any URL card with this URL (by checking authorId)
193 const allCards = this.cardRepository.getAllCards();
194 return allCards.some(
195 (card) =>
196 card.isUrlCard &&
197 card.url?.value === url &&
198 card.curatorId.value === userId,
199 );
200 }
201
202 async getCardsInCollection(
203 collectionId: string,
204 options: CardQueryOptions,
205 callingUserId?: string,
206 ): Promise<PaginatedQueryResult<CollectionCardQueryResultDTO>> {
207 try {
208 // Get the collection from the repository
209 const collectionIdObj = CollectionId.createFromString(collectionId);
210 if (collectionIdObj.isErr()) {
211 throw new Error(`Invalid collection ID: ${collectionId}`);
212 }
213
214 const collectionResult = await this.collectionRepository.findById(
215 collectionIdObj.value,
216 );
217 if (collectionResult.isErr()) {
218 throw collectionResult.error;
219 }
220
221 const collection = collectionResult.value;
222 if (!collection) {
223 return {
224 items: [],
225 totalCount: 0,
226 hasMore: false,
227 };
228 }
229
230 // Get cards that are in this collection
231 const allCards = this.cardRepository.getAllCards();
232 const collectionCardIds = new Set(
233 collection.cardIds.map((id) => id.getStringValue()),
234 );
235 const collectionCards = allCards
236 .filter(
237 (card) =>
238 collectionCardIds.has(card.cardId.getStringValue()) &&
239 card.isUrlCard,
240 )
241 .map((card) =>
242 this.toCollectionCardQueryResult(
243 this.cardToUrlCardQueryResult(card, callingUserId),
244 ),
245 );
246
247 // Sort cards
248 const sortedCards = this.sortCollectionCards(
249 collectionCards,
250 options.sortBy,
251 options.sortOrder,
252 );
253
254 // Apply pagination
255 const startIndex = (options.page - 1) * options.limit;
256 const endIndex = startIndex + options.limit;
257 const paginatedCards = sortedCards.slice(startIndex, endIndex);
258
259 return {
260 items: paginatedCards,
261 totalCount: collectionCards.length,
262 hasMore: endIndex < collectionCards.length,
263 };
264 } catch (error) {
265 throw new Error(
266 `Failed to query collection cards: ${error instanceof Error ? error.message : String(error)}`,
267 );
268 }
269 }
270
271 private sortCollectionCards(
272 cards: CollectionCardQueryResultDTO[],
273 sortBy: CardSortField,
274 sortOrder: SortOrder,
275 ): CollectionCardQueryResultDTO[] {
276 const sorted = [...cards].sort((a, b) => {
277 let comparison = 0;
278
279 switch (sortBy) {
280 case CardSortField.CREATED_AT:
281 comparison = a.createdAt.getTime() - b.createdAt.getTime();
282 break;
283 case CardSortField.UPDATED_AT:
284 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
285 break;
286 case CardSortField.LIBRARY_COUNT:
287 comparison = a.libraryCount - b.libraryCount;
288 break;
289 default:
290 comparison = 0;
291 }
292
293 return sortOrder === SortOrder.DESC ? -comparison : comparison;
294 });
295
296 return sorted;
297 }
298
299 private toCollectionCardQueryResult(
300 card: UrlCardQueryResultDTO,
301 ): CollectionCardQueryResultDTO {
302 return {
303 id: card.id,
304 type: CardTypeEnum.URL,
305 url: card.url,
306 cardContent: card.cardContent,
307 libraryCount: card.libraryCount,
308 urlLibraryCount: card.urlLibraryCount,
309 urlInLibrary: card.urlInLibrary,
310 createdAt: card.createdAt,
311 updatedAt: card.updatedAt,
312 authorId: card.authorId,
313 note: card.note,
314 };
315 }
316
317 async getUrlCardView(
318 cardId: string,
319 callingUserId?: string,
320 ): Promise<UrlCardViewDTO | null> {
321 const allCards = this.cardRepository.getAllCards();
322 const card = allCards.find((c) => c.cardId.getStringValue() === cardId);
323 if (!card || !card.isUrlCard) {
324 return null;
325 }
326
327 const urlCardResult = this.cardToUrlCardQueryResult(card, callingUserId);
328
329 // Get library memberships from the card itself
330 const libraries = card.libraryMemberships.map((membership) => ({
331 userId: membership.curatorId.value,
332 }));
333
334 // Find note cards with matching URL
335 const noteCard = allCards.find(
336 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value,
337 );
338
339 const note = noteCard
340 ? {
341 id: noteCard.cardId.getStringValue(),
342 text: noteCard.content.noteContent?.text || '',
343 }
344 : undefined;
345
346 return {
347 ...urlCardResult,
348 libraries,
349 note,
350 };
351 }
352
353 async getUrlCardBasic(
354 cardId: string,
355 callingUserId?: string,
356 ): Promise<UrlCardView | null> {
357 const allCards = this.cardRepository.getAllCards();
358 const card = allCards.find((c) => c.cardId.getStringValue() === cardId);
359 if (!card || !card.isUrlCard) {
360 return null;
361 }
362
363 // Find note card by the same author with matching parent card ID
364 const noteCard = allCards.find(
365 (c) =>
366 c.type.value === 'NOTE' &&
367 c.parentCardId?.equals(card.cardId) &&
368 c.curatorId.value === card.curatorId.value, // Only notes by the same author
369 );
370
371 const note = noteCard
372 ? {
373 id: noteCard.cardId.getStringValue(),
374 text: noteCard.content.noteContent?.text || '',
375 }
376 : undefined;
377
378 // Compute urlInLibrary if callingUserId is provided
379 const urlInLibrary = callingUserId
380 ? this.isUrlInUserLibrary(
381 card.content.urlContent!.url.value,
382 callingUserId,
383 )
384 : undefined;
385
386 return {
387 id: card.cardId.getStringValue(),
388 type: CardTypeEnum.URL,
389 url: card.content.urlContent!.url.value,
390 cardContent: {
391 url: card.content.urlContent!.url.value,
392 title: card.content.urlContent!.metadata?.title,
393 description: card.content.urlContent!.metadata?.description,
394 author: card.content.urlContent!.metadata?.author,
395 thumbnailUrl: card.content.urlContent!.metadata?.imageUrl,
396 },
397 libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()),
398 urlLibraryCount: this.getUrlLibraryCount(
399 card.content.urlContent!.url.value,
400 ),
401 urlInLibrary,
402 createdAt: card.createdAt,
403 updatedAt: card.updatedAt,
404 authorId: card.curatorId.value,
405 note,
406 };
407 }
408
409 async getLibrariesForCard(cardId: string): Promise<string[]> {
410 const allCards = this.cardRepository.getAllCards();
411 const card = allCards.find((c) => c.cardId.getStringValue() === cardId);
412
413 if (!card) {
414 return [];
415 }
416
417 return card.libraryMemberships.map(
418 (membership) => membership.curatorId.value,
419 );
420 }
421
422 async getLibrariesForUrl(
423 url: string,
424 options: CardQueryOptions,
425 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> {
426 try {
427 // Get all cards and filter by URL
428 const allCards = this.cardRepository.getAllCards();
429 const urlCards = allCards.filter(
430 (card) => card.isUrlCard && card.url?.value === url,
431 );
432
433 // Create library entries for each card
434 const libraries: LibraryForUrlDTO[] = [];
435 for (const card of urlCards) {
436 // Skip cards without urlContent (should not happen since we filtered for URL cards)
437 if (!card.content.urlContent) {
438 continue;
439 }
440
441 for (const membership of card.libraryMemberships) {
442 const noteCard = allCards.find(
443 (c) => c.isNoteCard && c.parentCardId?.equals(card.cardId),
444 );
445
446 const urlLibraryCount = this.getUrlLibraryCount(url);
447
448 libraries.push({
449 userId: membership.curatorId.value,
450 card: {
451 id: card.cardId.getStringValue(),
452 url: card.content.urlContent.url.value,
453 cardContent: {
454 url: card.content.urlContent.url.value,
455 title: card.content.urlContent.metadata?.title,
456 description: card.content.urlContent.metadata?.description,
457 author: card.content.urlContent.metadata?.author,
458 thumbnailUrl: card.content.urlContent.metadata?.imageUrl,
459 },
460 libraryCount: this.getLibraryCountForCard(
461 card.cardId.getStringValue(),
462 ),
463 urlLibraryCount,
464 urlInLibrary: true,
465 createdAt: card.createdAt,
466 updatedAt: card.updatedAt,
467 note: noteCard
468 ? {
469 id: noteCard.cardId.getStringValue(),
470 text: noteCard.content.noteContent?.text || '',
471 }
472 : undefined,
473 },
474 });
475 }
476 }
477
478 // Sort libraries (by userId for consistency)
479 const sortedLibraries = this.sortLibraries(
480 libraries,
481 options.sortBy,
482 options.sortOrder,
483 );
484
485 // Apply pagination
486 const startIndex = (options.page - 1) * options.limit;
487 const endIndex = startIndex + options.limit;
488 const paginatedLibraries = sortedLibraries.slice(startIndex, endIndex);
489
490 return {
491 items: paginatedLibraries,
492 totalCount: libraries.length,
493 hasMore: endIndex < libraries.length,
494 };
495 } catch (error) {
496 throw new Error(
497 `Failed to query libraries for URL: ${error instanceof Error ? error.message : String(error)}`,
498 );
499 }
500 }
501
502 private sortLibraries(
503 libraries: LibraryForUrlDTO[],
504 sortBy: CardSortField,
505 sortOrder: SortOrder,
506 ): LibraryForUrlDTO[] {
507 const sorted = [...libraries].sort((a, b) => {
508 let comparison = 0;
509
510 switch (sortBy) {
511 case CardSortField.CREATED_AT:
512 case CardSortField.UPDATED_AT:
513 case CardSortField.LIBRARY_COUNT:
514 // For libraries, we'll sort by userId as a fallback
515 comparison = a.userId.localeCompare(b.userId);
516 break;
517 default:
518 comparison = a.userId.localeCompare(b.userId);
519 }
520
521 return sortOrder === SortOrder.DESC ? -comparison : comparison;
522 });
523
524 return sorted;
525 }
526
527 async getNoteCardsForUrl(
528 url: string,
529 options: CardQueryOptions,
530 ): Promise<PaginatedQueryResult<NoteCardForUrlRawDTO>> {
531 try {
532 // Get all note cards with the specified URL
533 const allCards = this.cardRepository.getAllCards();
534 const noteCards = allCards
535 .filter((card) => card.isNoteCard && card.url?.value === url)
536 .map((card) => ({
537 id: card.cardId.getStringValue(),
538 note: card.content.noteContent?.text || '',
539 authorId: card.curatorId.value,
540 createdAt: card.createdAt,
541 updatedAt: card.updatedAt,
542 }));
543
544 // Sort note cards
545 const sortedNotes = this.sortNoteCards(
546 noteCards,
547 options.sortBy,
548 options.sortOrder,
549 );
550
551 // Apply pagination
552 const startIndex = (options.page - 1) * options.limit;
553 const endIndex = startIndex + options.limit;
554 const paginatedNotes = sortedNotes.slice(startIndex, endIndex);
555
556 return {
557 items: paginatedNotes,
558 totalCount: noteCards.length,
559 hasMore: endIndex < noteCards.length,
560 };
561 } catch (error) {
562 throw new Error(
563 `Failed to query note cards for URL: ${error instanceof Error ? error.message : String(error)}`,
564 );
565 }
566 }
567
568 private sortNoteCards(
569 notes: NoteCardForUrlRawDTO[],
570 sortBy: CardSortField,
571 sortOrder: SortOrder,
572 ): NoteCardForUrlRawDTO[] {
573 const sorted = [...notes].sort((a, b) => {
574 let comparison = 0;
575
576 switch (sortBy) {
577 case CardSortField.CREATED_AT:
578 comparison = a.createdAt.getTime() - b.createdAt.getTime();
579 break;
580 case CardSortField.UPDATED_AT:
581 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
582 break;
583 case CardSortField.LIBRARY_COUNT:
584 // For note cards, sort by authorId as fallback
585 comparison = a.authorId.localeCompare(b.authorId);
586 break;
587 default:
588 comparison = 0;
589 }
590
591 return sortOrder === SortOrder.DESC ? -comparison : comparison;
592 });
593
594 return sorted;
595 }
596
597 clear(): void {
598 // No separate state to clear
599 }
600}