A social knowledge tool for researchers built on ATProto
1import { 2 ICardQueryRepository, 3 CardQueryOptions, 4 UrlCardQueryResultDTO, 5 CollectionCardQueryResultDTO, 6 UrlCardViewDTO, 7 PaginatedQueryResult, 8 CardSortField, 9 SortOrder, 10 LibraryForUrlDTO, 11 NoteCardForUrlDTO, 12} from '../../domain/ICardQueryRepository'; 13import { CardTypeEnum } from '../../domain/value-objects/CardType'; 14import { InMemoryCardRepository } from './InMemoryCardRepository'; 15import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; 16import { Card } from '../../domain/Card'; 17import { CollectionId } from '../../domain/value-objects/CollectionId'; 18import { CuratorId } from '../../domain/value-objects/CuratorId'; 19 20export class InMemoryCardQueryRepository implements ICardQueryRepository { 21 constructor( 22 private cardRepository: InMemoryCardRepository, 23 private collectionRepository: InMemoryCollectionRepository, 24 ) {} 25 26 async getUrlCardsOfUser( 27 userId: string, 28 options: CardQueryOptions, 29 ): Promise<PaginatedQueryResult<UrlCardQueryResultDTO>> { 30 try { 31 // Get all cards and filter by user's library membership 32 const allCards = this.cardRepository.getAllCards(); 33 const userCards = allCards 34 .filter( 35 (card) => 36 card.isUrlCard && 37 card.isInLibrary(CuratorId.create(userId).unwrap()), 38 ) 39 .map((card) => this.cardToUrlCardQueryResult(card)); 40 41 // Sort cards 42 const sortedCards = this.sortCards( 43 userCards, 44 options.sortBy, 45 options.sortOrder, 46 ); 47 48 // Apply pagination 49 const startIndex = (options.page - 1) * options.limit; 50 const endIndex = startIndex + options.limit; 51 const paginatedCards = sortedCards.slice(startIndex, endIndex); 52 53 return { 54 items: paginatedCards, 55 totalCount: userCards.length, 56 hasMore: endIndex < userCards.length, 57 }; 58 } catch (error) { 59 throw new Error( 60 `Failed to query URL cards: ${error instanceof Error ? error.message : String(error)}`, 61 ); 62 } 63 } 64 65 private sortCards( 66 cards: UrlCardQueryResultDTO[], 67 sortBy: CardSortField, 68 sortOrder: SortOrder, 69 ): UrlCardQueryResultDTO[] { 70 const sorted = [...cards].sort((a, b) => { 71 let comparison = 0; 72 73 switch (sortBy) { 74 case CardSortField.CREATED_AT: 75 comparison = a.createdAt.getTime() - b.createdAt.getTime(); 76 break; 77 case CardSortField.UPDATED_AT: 78 comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 79 break; 80 case CardSortField.LIBRARY_COUNT: 81 comparison = a.libraryCount - b.libraryCount; 82 break; 83 default: 84 comparison = 0; 85 } 86 87 return sortOrder === SortOrder.DESC ? -comparison : comparison; 88 }); 89 90 return sorted; 91 } 92 93 private cardToUrlCardQueryResult(card: Card): UrlCardQueryResultDTO { 94 if (!card.isUrlCard || !card.content.urlContent) { 95 throw new Error('Card is not a URL card'); 96 } 97 98 // Find collections this card belongs to by querying the collection repository 99 const allCollections = this.collectionRepository.getAllCollections(); 100 const collections: { id: string; name: string; authorId: string }[] = []; 101 102 for (const collection of allCollections) { 103 if ( 104 collection.cardIds.some( 105 (cardId) => cardId.getStringValue() === card.cardId.getStringValue(), 106 ) 107 ) { 108 collections.push({ 109 id: collection.collectionId.getStringValue(), 110 name: collection.name.value, 111 authorId: collection.authorId.value, 112 }); 113 } 114 } 115 116 // Find note cards with matching URL 117 const allCards = this.cardRepository.getAllCards(); 118 const noteCard = allCards.find( 119 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value, 120 ); 121 122 const note = noteCard 123 ? { 124 id: noteCard.cardId.getStringValue(), 125 text: noteCard.content.noteContent?.text || '', 126 } 127 : undefined; 128 129 return { 130 id: card.cardId.getStringValue(), 131 type: CardTypeEnum.URL, 132 url: card.content.urlContent.url.value, 133 cardContent: { 134 url: card.content.urlContent.url.value, 135 title: card.content.urlContent.metadata?.title, 136 description: card.content.urlContent.metadata?.description, 137 author: card.content.urlContent.metadata?.author, 138 thumbnailUrl: card.content.urlContent.metadata?.imageUrl, 139 }, 140 libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), 141 createdAt: card.createdAt, 142 updatedAt: card.updatedAt, 143 collections, 144 note, 145 }; 146 } 147 148 private getLibraryCountForCard(cardId: string): number { 149 const card = this.cardRepository.getStoredCard({ 150 getStringValue: () => cardId, 151 } as any); 152 return card ? card.libraryMembershipCount : 0; 153 } 154 155 async getCardsInCollection( 156 collectionId: string, 157 options: CardQueryOptions, 158 ): Promise<PaginatedQueryResult<CollectionCardQueryResultDTO>> { 159 try { 160 // Get the collection from the repository 161 const collectionIdObj = CollectionId.createFromString(collectionId); 162 if (collectionIdObj.isErr()) { 163 throw new Error(`Invalid collection ID: ${collectionId}`); 164 } 165 166 const collectionResult = await this.collectionRepository.findById( 167 collectionIdObj.value, 168 ); 169 if (collectionResult.isErr()) { 170 throw collectionResult.error; 171 } 172 173 const collection = collectionResult.value; 174 if (!collection) { 175 return { 176 items: [], 177 totalCount: 0, 178 hasMore: false, 179 }; 180 } 181 182 // Get cards that are in this collection 183 const allCards = this.cardRepository.getAllCards(); 184 const collectionCardIds = new Set( 185 collection.cardIds.map((id) => id.getStringValue()), 186 ); 187 const collectionCards = allCards 188 .filter( 189 (card) => 190 collectionCardIds.has(card.cardId.getStringValue()) && 191 card.isUrlCard, 192 ) 193 .map((card) => 194 this.toCollectionCardQueryResult(this.cardToUrlCardQueryResult(card)), 195 ); 196 197 // Sort cards 198 const sortedCards = this.sortCollectionCards( 199 collectionCards, 200 options.sortBy, 201 options.sortOrder, 202 ); 203 204 // Apply pagination 205 const startIndex = (options.page - 1) * options.limit; 206 const endIndex = startIndex + options.limit; 207 const paginatedCards = sortedCards.slice(startIndex, endIndex); 208 209 return { 210 items: paginatedCards, 211 totalCount: collectionCards.length, 212 hasMore: endIndex < collectionCards.length, 213 }; 214 } catch (error) { 215 throw new Error( 216 `Failed to query collection cards: ${error instanceof Error ? error.message : String(error)}`, 217 ); 218 } 219 } 220 221 private sortCollectionCards( 222 cards: CollectionCardQueryResultDTO[], 223 sortBy: CardSortField, 224 sortOrder: SortOrder, 225 ): CollectionCardQueryResultDTO[] { 226 const sorted = [...cards].sort((a, b) => { 227 let comparison = 0; 228 229 switch (sortBy) { 230 case CardSortField.CREATED_AT: 231 comparison = a.createdAt.getTime() - b.createdAt.getTime(); 232 break; 233 case CardSortField.UPDATED_AT: 234 comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 235 break; 236 case CardSortField.LIBRARY_COUNT: 237 comparison = a.libraryCount - b.libraryCount; 238 break; 239 default: 240 comparison = 0; 241 } 242 243 return sortOrder === SortOrder.DESC ? -comparison : comparison; 244 }); 245 246 return sorted; 247 } 248 249 private toCollectionCardQueryResult( 250 card: UrlCardQueryResultDTO, 251 ): CollectionCardQueryResultDTO { 252 return { 253 id: card.id, 254 type: CardTypeEnum.URL, 255 url: card.url, 256 cardContent: card.cardContent, 257 libraryCount: card.libraryCount, 258 createdAt: card.createdAt, 259 updatedAt: card.updatedAt, 260 note: card.note, 261 }; 262 } 263 264 async getUrlCardView(cardId: string): Promise<UrlCardViewDTO | null> { 265 const allCards = this.cardRepository.getAllCards(); 266 const card = allCards.find((c) => c.cardId.getStringValue() === cardId); 267 if (!card || !card.isUrlCard) { 268 return null; 269 } 270 271 const urlCardResult = this.cardToUrlCardQueryResult(card); 272 273 // Get library memberships from the card itself 274 const libraries = card.libraryMemberships.map((membership) => ({ 275 userId: membership.curatorId.value, 276 })); 277 278 // Find note cards with matching URL 279 const noteCard = allCards.find( 280 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value, 281 ); 282 283 const note = noteCard 284 ? { 285 id: noteCard.cardId.getStringValue(), 286 text: noteCard.content.noteContent?.text || '', 287 } 288 : undefined; 289 290 return { 291 ...urlCardResult, 292 libraries, 293 note, 294 }; 295 } 296 297 async getLibrariesForCard(cardId: string): Promise<string[]> { 298 const allCards = this.cardRepository.getAllCards(); 299 const card = allCards.find((c) => c.cardId.getStringValue() === cardId); 300 301 if (!card) { 302 return []; 303 } 304 305 return card.libraryMemberships.map( 306 (membership) => membership.curatorId.value, 307 ); 308 } 309 310 async getLibrariesForUrl( 311 url: string, 312 options: CardQueryOptions, 313 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 314 try { 315 // Get all cards and filter by URL 316 const allCards = this.cardRepository.getAllCards(); 317 const urlCards = allCards.filter( 318 (card) => card.isUrlCard && card.url?.value === url, 319 ); 320 321 // Create library entries for each card 322 const libraries: LibraryForUrlDTO[] = []; 323 for (const card of urlCards) { 324 for (const membership of card.libraryMemberships) { 325 libraries.push({ 326 userId: membership.curatorId.value, 327 cardId: card.cardId.getStringValue(), 328 }); 329 } 330 } 331 332 // Sort libraries (by userId for consistency) 333 const sortedLibraries = this.sortLibraries( 334 libraries, 335 options.sortBy, 336 options.sortOrder, 337 ); 338 339 // Apply pagination 340 const startIndex = (options.page - 1) * options.limit; 341 const endIndex = startIndex + options.limit; 342 const paginatedLibraries = sortedLibraries.slice(startIndex, endIndex); 343 344 return { 345 items: paginatedLibraries, 346 totalCount: libraries.length, 347 hasMore: endIndex < libraries.length, 348 }; 349 } catch (error) { 350 throw new Error( 351 `Failed to query libraries for URL: ${error instanceof Error ? error.message : String(error)}`, 352 ); 353 } 354 } 355 356 private sortLibraries( 357 libraries: LibraryForUrlDTO[], 358 sortBy: CardSortField, 359 sortOrder: SortOrder, 360 ): LibraryForUrlDTO[] { 361 const sorted = [...libraries].sort((a, b) => { 362 let comparison = 0; 363 364 switch (sortBy) { 365 case CardSortField.CREATED_AT: 366 case CardSortField.UPDATED_AT: 367 case CardSortField.LIBRARY_COUNT: 368 // For libraries, we'll sort by userId as a fallback 369 comparison = a.userId.localeCompare(b.userId); 370 break; 371 default: 372 comparison = a.userId.localeCompare(b.userId); 373 } 374 375 return sortOrder === SortOrder.DESC ? -comparison : comparison; 376 }); 377 378 return sorted; 379 } 380 381 async getNoteCardsForUrl( 382 url: string, 383 options: CardQueryOptions, 384 ): Promise<PaginatedQueryResult<NoteCardForUrlDTO>> { 385 try { 386 // Get all note cards with the specified URL 387 const allCards = this.cardRepository.getAllCards(); 388 const noteCards = allCards 389 .filter((card) => card.isNoteCard && card.url?.value === url) 390 .map((card) => ({ 391 id: card.cardId.getStringValue(), 392 note: card.content.noteContent?.text || '', 393 authorId: card.curatorId.value, 394 createdAt: card.createdAt, 395 updatedAt: card.updatedAt, 396 })); 397 398 // Sort note cards 399 const sortedNotes = this.sortNoteCards( 400 noteCards, 401 options.sortBy, 402 options.sortOrder, 403 ); 404 405 // Apply pagination 406 const startIndex = (options.page - 1) * options.limit; 407 const endIndex = startIndex + options.limit; 408 const paginatedNotes = sortedNotes.slice(startIndex, endIndex); 409 410 return { 411 items: paginatedNotes, 412 totalCount: noteCards.length, 413 hasMore: endIndex < noteCards.length, 414 }; 415 } catch (error) { 416 throw new Error( 417 `Failed to query note cards for URL: ${error instanceof Error ? error.message : String(error)}`, 418 ); 419 } 420 } 421 422 private sortNoteCards( 423 notes: NoteCardForUrlDTO[], 424 sortBy: CardSortField, 425 sortOrder: SortOrder, 426 ): NoteCardForUrlDTO[] { 427 const sorted = [...notes].sort((a, b) => { 428 let comparison = 0; 429 430 switch (sortBy) { 431 case CardSortField.CREATED_AT: 432 comparison = a.createdAt.getTime() - b.createdAt.getTime(); 433 break; 434 case CardSortField.UPDATED_AT: 435 comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 436 break; 437 case CardSortField.LIBRARY_COUNT: 438 // For note cards, sort by authorId as fallback 439 comparison = a.authorId.localeCompare(b.authorId); 440 break; 441 default: 442 comparison = 0; 443 } 444 445 return sortOrder === SortOrder.DESC ? -comparison : comparison; 446 }); 447 448 return sorted; 449 } 450 451 clear(): void { 452 // No separate state to clear 453 } 454}