A social knowledge tool for researchers built on ATProto
at main 600 lines 18 kB view raw
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}