A social knowledge tool for researchers built on ATProto
45
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: update GetCollectionsForUrlUseCase to support sorting and pagination

Co-authored-by: aider (anthropic/claude-sonnet-4-5-20250929) <aider@aider.chat>

+343 -11
+19 -1
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
··· 1 1 import { Result, ok, err } from '../../../../../shared/core/Result'; 2 2 import { UseCase } from '../../../../../shared/core/UseCase'; 3 - import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; 3 + import { 4 + ICollectionQueryRepository, 5 + CollectionSortField, 6 + SortOrder, 7 + } from '../../../domain/ICollectionQueryRepository'; 4 8 import { URL } from '../../../domain/value-objects/URL'; 5 9 6 10 export interface GetCollectionsForUrlQuery { 7 11 url: string; 8 12 page?: number; 9 13 limit?: number; 14 + sortBy?: CollectionSortField; 15 + sortOrder?: SortOrder; 10 16 } 11 17 12 18 export interface CollectionForUrlDTO { ··· 26 32 hasMore: boolean; 27 33 limit: number; 28 34 }; 35 + sorting: { 36 + sortBy: CollectionSortField; 37 + sortOrder: SortOrder; 38 + }; 29 39 } 30 40 31 41 export class ValidationError extends Error { ··· 55 65 // Set defaults 56 66 const page = query.page || 1; 57 67 const limit = Math.min(query.limit || 20, 100); // Cap at 100 68 + const sortBy = query.sortBy || CollectionSortField.NAME; 69 + const sortOrder = query.sortOrder || SortOrder.ASC; 58 70 59 71 try { 60 72 // Execute query to get collections containing cards with this URL ··· 63 75 { 64 76 page, 65 77 limit, 78 + sortBy, 79 + sortOrder, 66 80 }, 67 81 ); 68 82 ··· 74 88 totalCount: result.totalCount, 75 89 hasMore: result.hasMore, 76 90 limit, 91 + }, 92 + sorting: { 93 + sortBy, 94 + sortOrder, 77 95 }, 78 96 }); 79 97 } catch (error) {
+2
src/modules/cards/domain/ICollectionQueryRepository.ts
··· 53 53 export interface CollectionForUrlQueryOptions { 54 54 page: number; 55 55 limit: number; 56 + sortBy: CollectionSortField; 57 + sortOrder: SortOrder; 56 58 } 57 59 58 60 export interface ICollectionQueryRepository {
+10 -4
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 160 160 options: CollectionForUrlQueryOptions, 161 161 ): Promise<PaginatedQueryResult<CollectionForUrlDTO>> { 162 162 try { 163 - const { page, limit } = options; 163 + const { page, limit, sortBy, sortOrder } = options; 164 164 const offset = (page - 1) * limit; 165 + 166 + // Build the sort order 167 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 165 168 166 169 // Find all URL cards with this URL 167 170 const urlCardsQuery = this.db ··· 183 186 184 187 const cardIds = urlCardsResult.map((card) => card.id); 185 188 186 - // Find all collections that contain any of these cards with pagination 189 + // Find all collections that contain any of these cards with pagination and sorting 187 190 const collectionsQuery = this.db 188 191 .selectDistinct({ 189 192 id: collections.id, ··· 191 194 description: collections.description, 192 195 authorId: collections.authorId, 193 196 uri: publishedRecords.uri, 197 + createdAt: collections.createdAt, 198 + updatedAt: collections.updatedAt, 199 + cardCount: collections.cardCount, 194 200 }) 195 201 .from(collections) 196 202 .leftJoin( ··· 202 208 eq(collections.id, collectionCards.collectionId), 203 209 ) 204 210 .where(inArray(collectionCards.cardId, cardIds)) 205 - .orderBy(asc(collections.name)) 211 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 206 212 .limit(limit) 207 213 .offset(offset); 208 214 ··· 254 260 case CollectionSortField.CARD_COUNT: 255 261 return collections.cardCount; 256 262 default: 257 - return collections.updatedAt; 263 + return collections.name; 258 264 } 259 265 } 260 266 }
+207
src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts
··· 8 8 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 9 9 import { URL } from '../../domain/value-objects/URL'; 10 10 import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 11 + import { CollectionSortField, SortOrder } from '../../domain/ICollectionQueryRepository'; 11 12 12 13 describe('GetCollectionsForUrlUseCase', () => { 13 14 let useCase: GetCollectionsForUrlUseCase; ··· 504 505 505 506 expect(response.pagination.currentPage).toBe(1); 506 507 expect(response.pagination.limit).toBe(20); 508 + }); 509 + }); 510 + 511 + describe('Sorting', () => { 512 + it('should use default sorting parameters (NAME, ASC)', async () => { 513 + const testUrl = 'https://example.com/test-article'; 514 + 515 + const query = { 516 + url: testUrl, 517 + }; 518 + 519 + const result = await useCase.execute(query); 520 + expect(result.isOk()).toBe(true); 521 + const response = result.unwrap(); 522 + 523 + expect(response.sorting.sortBy).toBe(CollectionSortField.NAME); 524 + expect(response.sorting.sortOrder).toBe(SortOrder.ASC); 525 + }); 526 + 527 + it('should use provided sorting parameters', async () => { 528 + const testUrl = 'https://example.com/test-article'; 529 + 530 + const query = { 531 + url: testUrl, 532 + sortBy: CollectionSortField.CREATED_AT, 533 + sortOrder: SortOrder.DESC, 534 + }; 535 + 536 + const result = await useCase.execute(query); 537 + expect(result.isOk()).toBe(true); 538 + const response = result.unwrap(); 539 + 540 + expect(response.sorting.sortBy).toBe(CollectionSortField.CREATED_AT); 541 + expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 542 + }); 543 + 544 + it('should sort collections by name in ascending order by default', async () => { 545 + const testUrl = 'https://example.com/article'; 546 + const url = URL.create(testUrl).unwrap(); 547 + 548 + // Create URL cards 549 + const card1 = new CardBuilder() 550 + .withCuratorId(curator1.value) 551 + .withType(CardTypeEnum.URL) 552 + .withUrl(url) 553 + .build(); 554 + 555 + const card2 = new CardBuilder() 556 + .withCuratorId(curator2.value) 557 + .withType(CardTypeEnum.URL) 558 + .withUrl(url) 559 + .build(); 560 + 561 + const card3 = new CardBuilder() 562 + .withCuratorId(curator3.value) 563 + .withType(CardTypeEnum.URL) 564 + .withUrl(url) 565 + .build(); 566 + 567 + if ( 568 + card1 instanceof Error || 569 + card2 instanceof Error || 570 + card3 instanceof Error 571 + ) { 572 + throw new Error('Failed to create cards'); 573 + } 574 + 575 + card1.addToLibrary(curator1); 576 + card2.addToLibrary(curator2); 577 + card3.addToLibrary(curator3); 578 + 579 + await cardRepository.save(card1); 580 + await cardRepository.save(card2); 581 + await cardRepository.save(card3); 582 + 583 + // Create collections with names that should be sorted 584 + const collectionZ = new CollectionBuilder() 585 + .withAuthorId(curator1.value) 586 + .withName('Zebra Collection') 587 + .build(); 588 + 589 + const collectionA = new CollectionBuilder() 590 + .withAuthorId(curator2.value) 591 + .withName('Apple Collection') 592 + .build(); 593 + 594 + const collectionM = new CollectionBuilder() 595 + .withAuthorId(curator3.value) 596 + .withName('Mango Collection') 597 + .build(); 598 + 599 + if ( 600 + collectionZ instanceof Error || 601 + collectionA instanceof Error || 602 + collectionM instanceof Error 603 + ) { 604 + throw new Error('Failed to create collections'); 605 + } 606 + 607 + collectionZ.addCard(card1.cardId, curator1); 608 + collectionA.addCard(card2.cardId, curator2); 609 + collectionM.addCard(card3.cardId, curator3); 610 + 611 + await collectionRepository.save(collectionZ); 612 + await collectionRepository.save(collectionA); 613 + await collectionRepository.save(collectionM); 614 + 615 + const query = { 616 + url: testUrl, 617 + }; 618 + 619 + const result = await useCase.execute(query); 620 + expect(result.isOk()).toBe(true); 621 + const response = result.unwrap(); 622 + 623 + expect(response.collections).toHaveLength(3); 624 + expect(response.collections[0]!.name).toBe('Apple Collection'); 625 + expect(response.collections[1]!.name).toBe('Mango Collection'); 626 + expect(response.collections[2]!.name).toBe('Zebra Collection'); 627 + }); 628 + 629 + it('should sort collections by name in descending order', async () => { 630 + const testUrl = 'https://example.com/article'; 631 + const url = URL.create(testUrl).unwrap(); 632 + 633 + // Create URL cards 634 + const card1 = new CardBuilder() 635 + .withCuratorId(curator1.value) 636 + .withType(CardTypeEnum.URL) 637 + .withUrl(url) 638 + .build(); 639 + 640 + const card2 = new CardBuilder() 641 + .withCuratorId(curator2.value) 642 + .withType(CardTypeEnum.URL) 643 + .withUrl(url) 644 + .build(); 645 + 646 + const card3 = new CardBuilder() 647 + .withCuratorId(curator3.value) 648 + .withType(CardTypeEnum.URL) 649 + .withUrl(url) 650 + .build(); 651 + 652 + if ( 653 + card1 instanceof Error || 654 + card2 instanceof Error || 655 + card3 instanceof Error 656 + ) { 657 + throw new Error('Failed to create cards'); 658 + } 659 + 660 + card1.addToLibrary(curator1); 661 + card2.addToLibrary(curator2); 662 + card3.addToLibrary(curator3); 663 + 664 + await cardRepository.save(card1); 665 + await cardRepository.save(card2); 666 + await cardRepository.save(card3); 667 + 668 + // Create collections with names that should be sorted 669 + const collectionZ = new CollectionBuilder() 670 + .withAuthorId(curator1.value) 671 + .withName('Zebra Collection') 672 + .build(); 673 + 674 + const collectionA = new CollectionBuilder() 675 + .withAuthorId(curator2.value) 676 + .withName('Apple Collection') 677 + .build(); 678 + 679 + const collectionM = new CollectionBuilder() 680 + .withAuthorId(curator3.value) 681 + .withName('Mango Collection') 682 + .build(); 683 + 684 + if ( 685 + collectionZ instanceof Error || 686 + collectionA instanceof Error || 687 + collectionM instanceof Error 688 + ) { 689 + throw new Error('Failed to create collections'); 690 + } 691 + 692 + collectionZ.addCard(card1.cardId, curator1); 693 + collectionA.addCard(card2.cardId, curator2); 694 + collectionM.addCard(card3.cardId, curator3); 695 + 696 + await collectionRepository.save(collectionZ); 697 + await collectionRepository.save(collectionA); 698 + await collectionRepository.save(collectionM); 699 + 700 + const query = { 701 + url: testUrl, 702 + sortBy: CollectionSortField.NAME, 703 + sortOrder: SortOrder.DESC, 704 + }; 705 + 706 + const result = await useCase.execute(query); 707 + expect(result.isOk()).toBe(true); 708 + const response = result.unwrap(); 709 + 710 + expect(response.collections).toHaveLength(3); 711 + expect(response.collections[0]!.name).toBe('Zebra Collection'); 712 + expect(response.collections[1]!.name).toBe('Mango Collection'); 713 + expect(response.collections[2]!.name).toBe('Apple Collection'); 507 714 }); 508 715 }); 509 716
+99 -2
src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts
··· 21 21 import { createTestSchema } from '../test-utils/createTestSchema'; 22 22 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 23 23 import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 24 + import { CollectionSortField, SortOrder } from '../../domain/ICollectionQueryRepository'; 24 25 25 26 describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { 26 27 let container: StartedPostgreSqlContainer; ··· 156 157 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 157 158 page: 1, 158 159 limit: 10, 160 + sortBy: CollectionSortField.NAME, 161 + sortOrder: SortOrder.ASC, 159 162 }); 160 163 161 164 // Verify the result ··· 201 204 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 202 205 page: 1, 203 206 limit: 10, 207 + sortBy: CollectionSortField.NAME, 208 + sortOrder: SortOrder.ASC, 204 209 }); 205 210 206 211 expect(result.items).toHaveLength(0); ··· 254 259 const result = await queryRepository.getCollectionsWithUrl(testUrl1, { 255 260 page: 1, 256 261 limit: 10, 262 + sortBy: CollectionSortField.NAME, 263 + sortOrder: SortOrder.ASC, 257 264 }); 258 265 259 266 expect(result.items).toHaveLength(1); ··· 304 311 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 305 312 page: 1, 306 313 limit: 10, 314 + sortBy: CollectionSortField.NAME, 315 + sortOrder: SortOrder.ASC, 307 316 }); 308 317 309 318 expect(result.items).toHaveLength(3); ··· 346 355 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 347 356 page: 1, 348 357 limit: 10, 358 + sortBy: CollectionSortField.NAME, 359 + sortOrder: SortOrder.ASC, 349 360 }); 350 361 351 362 expect(result.items).toHaveLength(1); ··· 391 402 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 392 403 page: 1, 393 404 limit: 10, 405 + sortBy: CollectionSortField.NAME, 406 + sortOrder: SortOrder.ASC, 394 407 }); 395 408 396 409 // Should return the collection only once, even though it has multiple cards with the URL ··· 443 456 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 444 457 page: 1, 445 458 limit: 10, 459 + sortBy: CollectionSortField.NAME, 460 + sortOrder: SortOrder.ASC, 446 461 }); 447 462 448 463 // Should only return the collection with the URL card, not the NOTE card ··· 468 483 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 469 484 page: 1, 470 485 limit: 10, 486 + sortBy: CollectionSortField.NAME, 487 + sortOrder: SortOrder.ASC, 471 488 }); 472 489 473 490 // Should return empty since card is not in any collection 474 491 expect(result.items).toHaveLength(0); 475 492 }); 476 493 477 - it('should return collections sorted alphabetically by name', async () => { 494 + it('should return collections sorted alphabetically by name in ascending order', async () => { 478 495 const testUrl = 'https://example.com/article'; 479 496 const url = URL.create(testUrl).unwrap(); 480 497 ··· 532 549 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 533 550 page: 1, 534 551 limit: 10, 552 + sortBy: CollectionSortField.NAME, 553 + sortOrder: SortOrder.ASC, 535 554 }); 536 555 537 556 expect(result.items).toHaveLength(3); ··· 539 558 expect(result.items[1]!.name).toBe('Mango Collection'); 540 559 expect(result.items[2]!.name).toBe('Zebra Collection'); 541 560 }); 561 + 562 + it('should return collections sorted alphabetically by name in descending order', async () => { 563 + const testUrl = 'https://example.com/article'; 564 + const url = URL.create(testUrl).unwrap(); 565 + 566 + // Create URL cards 567 + const card1 = new CardBuilder() 568 + .withCuratorId(curator1.value) 569 + .withType(CardTypeEnum.URL) 570 + .withUrl(url) 571 + .buildOrThrow(); 572 + 573 + const card2 = new CardBuilder() 574 + .withCuratorId(curator2.value) 575 + .withType(CardTypeEnum.URL) 576 + .withUrl(url) 577 + .buildOrThrow(); 578 + 579 + const card3 = new CardBuilder() 580 + .withCuratorId(curator3.value) 581 + .withType(CardTypeEnum.URL) 582 + .withUrl(url) 583 + .buildOrThrow(); 584 + 585 + card1.addToLibrary(curator1); 586 + card2.addToLibrary(curator2); 587 + card3.addToLibrary(curator3); 588 + 589 + await cardRepository.save(card1); 590 + await cardRepository.save(card2); 591 + await cardRepository.save(card3); 592 + 593 + // Create collections with names that should be sorted 594 + const collectionZ = new CollectionBuilder() 595 + .withAuthorId(curator1.value) 596 + .withName('Zebra Collection') 597 + .buildOrThrow(); 598 + 599 + const collectionA = new CollectionBuilder() 600 + .withAuthorId(curator2.value) 601 + .withName('Apple Collection') 602 + .buildOrThrow(); 603 + 604 + const collectionM = new CollectionBuilder() 605 + .withAuthorId(curator3.value) 606 + .withName('Mango Collection') 607 + .buildOrThrow(); 608 + 609 + collectionZ.addCard(card1.cardId, curator1); 610 + collectionA.addCard(card2.cardId, curator2); 611 + collectionM.addCard(card3.cardId, curator3); 612 + 613 + await collectionRepository.save(collectionZ); 614 + await collectionRepository.save(collectionA); 615 + await collectionRepository.save(collectionM); 616 + 617 + const result = await queryRepository.getCollectionsWithUrl(testUrl, { 618 + page: 1, 619 + limit: 10, 620 + sortBy: CollectionSortField.NAME, 621 + sortOrder: SortOrder.DESC, 622 + }); 623 + 624 + expect(result.items).toHaveLength(3); 625 + expect(result.items[0]!.name).toBe('Zebra Collection'); 626 + expect(result.items[1]!.name).toBe('Mango Collection'); 627 + expect(result.items[2]!.name).toBe('Apple Collection'); 628 + }); 542 629 }); 543 630 544 631 describe('Pagination', () => { ··· 580 667 const result1 = await queryRepository.getCollectionsWithUrl(testUrl, { 581 668 page: 1, 582 669 limit: 2, 670 + sortBy: CollectionSortField.NAME, 671 + sortOrder: SortOrder.ASC, 583 672 }); 584 673 585 674 expect(result1.items).toHaveLength(2); ··· 590 679 const result2 = await queryRepository.getCollectionsWithUrl(testUrl, { 591 680 page: 2, 592 681 limit: 2, 682 + sortBy: CollectionSortField.NAME, 683 + sortOrder: SortOrder.ASC, 593 684 }); 594 685 595 686 expect(result2.items).toHaveLength(2); ··· 600 691 const result3 = await queryRepository.getCollectionsWithUrl(testUrl, { 601 692 page: 3, 602 693 limit: 2, 694 + sortBy: CollectionSortField.NAME, 695 + sortOrder: SortOrder.ASC, 603 696 }); 604 697 605 698 expect(result3.items).toHaveLength(1); ··· 610 703 const allCollectionIds = [ 611 704 ...result1.items.map((c) => c.id), 612 705 ...result2.items.map((c) => c.id), 613 - ...result3.items.map((c) => c.i), 706 + ...result3.items.map((c) => c.id), 614 707 ]; 615 708 const uniqueCollectionIds = [...new Set(allCollectionIds)]; 616 709 expect(uniqueCollectionIds).toHaveLength(5); ··· 622 715 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 623 716 page: 2, 624 717 limit: 10, 718 + sortBy: CollectionSortField.NAME, 719 + sortOrder: SortOrder.ASC, 625 720 }); 626 721 627 722 expect(result.items).toHaveLength(0); ··· 655 750 const result = await queryRepository.getCollectionsWithUrl(testUrl, { 656 751 page: 10, 657 752 limit: 10, 753 + sortBy: CollectionSortField.NAME, 754 + sortOrder: SortOrder.ASC, 658 755 }); 659 756 660 757 expect(result.items).toHaveLength(0);
+6 -4
src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts
··· 50 50 options.sortOrder, 51 51 ); 52 52 53 - const startIndex = (options.page - 1) * options.limit; 53 + const start Index = (options.page - 1) * options.limit; 54 54 const endIndex = startIndex + options.limit; 55 55 const paginatedCollections = sortedCollections.slice( 56 56 startIndex, ··· 180 180 ), 181 181 ); 182 182 183 - // Sort by name (alphabetically) 184 - const sortedCollections = [...collectionsWithUrl].sort((a, b) => 185 - a.name.value.localeCompare(b.name.value), 183 + // Sort collections 184 + const sortedCollections = this.sortCollections( 185 + collectionsWithUrl, 186 + options.sortBy, 187 + options.sortOrder, 186 188 ); 187 189 188 190 // Apply pagination