A social knowledge tool for researchers built on ATProto

formatting and linting

Changed files
+82 -39
.claude
src
modules
+70 -26
.claude/guides/use-case-implementation-patterns.md
··· 3 3 This guide documents the patterns and best practices for implementing use cases in the Semble application, with full vertical stack integration. 4 4 5 5 ## Table of Contents 6 + 6 7 1. [Use Case Structure](#use-case-structure) 7 8 2. [Repository Patterns](#repository-patterns) 8 9 3. [Upsert Behavior Pattern](#upsert-behavior-pattern) ··· 100 101 When you need to find related entities (e.g., note cards for a URL card), add specific repository methods: 101 102 102 103 #### Interface Definition 104 + 103 105 ```typescript 104 106 // src/modules/cards/domain/ICardRepository.ts 105 107 export interface ICardRepository { 106 108 findById(id: CardId): Promise<Result<Card | null>>; 107 - findUsersUrlCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 108 - findUsersNoteCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 109 + findUsersUrlCardByUrl( 110 + url: URL, 111 + curatorId: CuratorId, 112 + ): Promise<Result<Card | null>>; 113 + findUsersNoteCardByUrl( 114 + url: URL, 115 + curatorId: CuratorId, 116 + ): Promise<Result<Card | null>>; 109 117 save(card: Card): Promise<Result<void>>; 110 118 delete(cardId: CardId): Promise<Result<void>>; 111 119 } 112 120 ``` 113 121 114 122 #### In-Memory Implementation (for tests) 123 + 115 124 ```typescript 116 125 async findUsersNoteCardByUrl( 117 126 url: URL, ··· 132 141 ``` 133 142 134 143 #### Drizzle Implementation 144 + 135 145 ```typescript 136 146 async findUsersNoteCardByUrl( 137 147 url: URL, ··· 170 180 ### Example: AddUrlToLibraryUseCase 171 181 172 182 This use case demonstrates modified upsert behavior: 183 + 173 184 - If URL card doesn't exist: Create it 174 185 - If URL card exists: Reuse it 175 186 - If note is provided and note card exists: **Update** the note ··· 178 189 179 190 ```typescript 180 191 // Check if note card already exists 181 - const existingNoteCardResult = 182 - await this.cardRepository.findUsersNoteCardByUrl(url, curatorId); 192 + const existingNoteCardResult = await this.cardRepository.findUsersNoteCardByUrl( 193 + url, 194 + curatorId, 195 + ); 183 196 if (existingNoteCardResult.isErr()) { 184 197 return err(AppError.UnexpectedError.create(existingNoteCardResult.error)); 185 198 } ··· 227 240 const url = 'https://example.com/existing'; 228 241 229 242 // First request creates URL card with note 230 - const firstRequest = { url, note: 'Original note', curatorId: curatorId.value }; 243 + const firstRequest = { 244 + url, 245 + note: 'Original note', 246 + curatorId: curatorId.value, 247 + }; 231 248 const firstResult = await useCase.execute(firstRequest); 232 249 expect(firstResult.isOk()).toBe(true); 233 250 const firstResponse = firstResult.unwrap(); 234 251 235 252 // Second request updates the note 236 - const secondRequest = { url, note: 'Updated note', curatorId: curatorId.value }; 253 + const secondRequest = { 254 + url, 255 + note: 'Updated note', 256 + curatorId: curatorId.value, 257 + }; 237 258 const secondResult = await useCase.execute(secondRequest); 238 259 expect(secondResult.isOk()).toBe(true); 239 260 const secondResponse = secondResult.unwrap(); ··· 257 278 ### Example: UpdateUrlCardAssociationsUseCase 258 279 259 280 This use case demonstrates explicit control: 281 + 260 282 - Requires URL card to already exist 261 283 - Provides separate controls for adding vs removing collections 262 284 - Can create or update notes ··· 267 289 url: string; 268 290 curatorId: string; 269 291 note?: string; 270 - addToCollections?: string[]; // Explicit add 292 + addToCollections?: string[]; // Explicit add 271 293 removeFromCollections?: string[]; // Explicit remove 272 294 } 273 295 274 296 export interface UpdateUrlCardAssociationsResponseDTO { 275 297 urlCardId: string; 276 298 noteCardId?: string; 277 - addedToCollections: string[]; // What was actually added 299 + addedToCollections: string[]; // What was actually added 278 300 removedFromCollections: string[]; // What was actually removed 279 301 } 280 302 ··· 352 374 const response = result.unwrap(); 353 375 expect(response.addedToCollections).toHaveLength(2); 354 376 expect(response.removedFromCollections).toHaveLength(1); 355 - expect(response.addedToCollections).toContain(collection2.collectionId.getStringValue()); 356 - expect(response.removedFromCollections).toContain(collection1.collectionId.getStringValue()); 377 + expect(response.addedToCollections).toContain( 378 + collection2.collectionId.getStringValue(), 379 + ); 380 + expect(response.removedFromCollections).toContain( 381 + collection1.collectionId.getStringValue(), 382 + ); 357 383 }); 358 384 ``` 359 385 ··· 373 399 eventPublisher = new FakeEventPublisher(); 374 400 domainService = new DomainService(repository, eventPublisher); 375 401 376 - useCase = new YourUseCase( 377 - repository, 378 - domainService, 379 - eventPublisher, 380 - ); 402 + useCase = new YourUseCase(repository, domainService, eventPublisher); 381 403 }); 382 404 383 405 afterEach(() => { ··· 388 410 describe('Feature group 1', () => { 389 411 it('should do X when Y', async () => { 390 412 // Arrange 391 - const request = { /* ... */ }; 413 + const request = { 414 + /* ... */ 415 + }; 392 416 393 417 // Act 394 418 const result = await useCase.execute(request); ··· 401 425 402 426 describe('Validation', () => { 403 427 it('should fail with invalid input', async () => { 404 - const result = await useCase.execute({ /* invalid */ }); 428 + const result = await useCase.execute({ 429 + /* invalid */ 430 + }); 405 431 406 432 expect(result.isErr()).toBe(true); 407 433 if (result.isErr()) { ··· 415 441 ### Test Coverage Checklist 416 442 417 443 For each use case, ensure tests cover: 444 + 418 445 - ✅ Happy path (basic functionality) 419 446 - ✅ Update existing entities 420 447 - ✅ Create new entities ··· 591 618 ## Best Practices 592 619 593 620 ### 1. Value Object Validation 621 + 594 622 Always validate and create value objects early in the use case: 623 + 595 624 ```typescript 596 625 const curatorIdResult = CuratorId.create(request.curatorId); 597 626 if (curatorIdResult.isErr()) { 598 - return err(new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`)); 627 + return err( 628 + new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`), 629 + ); 599 630 } 600 631 const curatorId = curatorIdResult.value; 601 632 ``` 602 633 603 634 ### 2. Error Handling 635 + 604 636 - Use `ValidationError` for business rule violations 605 637 - Use `AppError.UnexpectedError` for infrastructure errors 606 638 - Don't fail operations if event publishing fails (log instead) ··· 614 646 ``` 615 647 616 648 ### 3. Domain Services 649 + 617 650 Use domain services for cross-aggregate operations: 651 + 618 652 ```typescript 619 653 // ✅ Good - uses domain service 620 - await this.cardCollectionService.addCardToCollections(card, collectionIds, curatorId); 654 + await this.cardCollectionService.addCardToCollections( 655 + card, 656 + collectionIds, 657 + curatorId, 658 + ); 621 659 622 660 // ❌ Bad - directly manipulating aggregates 623 661 collection.addCard(card.cardId, curatorId); ··· 625 663 ``` 626 664 627 665 ### 4. Response DTOs 666 + 628 667 Return detailed information about what changed: 668 + 629 669 ```typescript 630 670 return ok({ 631 671 id: entity.id.getStringValue(), 632 672 created: !existingEntity, 633 673 updated: !!existingEntity, 634 - affectedCollections: updatedCollections.map(c => c.id.getStringValue()), 674 + affectedCollections: updatedCollections.map((c) => c.id.getStringValue()), 635 675 }); 636 676 ``` 637 677 638 678 ### 5. Idempotency 679 + 639 680 Design use cases to be idempotent when possible: 681 + 640 682 - Check existence before creating 641 683 - Only update if values actually changed 642 684 - Handle "already exists" gracefully 643 685 644 686 ## Common Patterns Summary 645 687 646 - | Pattern | When to Use | Key Characteristics | 647 - |---------|-------------|---------------------| 648 - | **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove | 649 - | **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist | 650 - | **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation | 651 - | **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions | 688 + | Pattern | When to Use | Key Characteristics | 689 + | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ | 690 + | **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove | 691 + | **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist | 692 + | **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation | 693 + | **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions | 652 694 653 695 ## References 654 696 655 697 ### Example Implementations 698 + 656 699 - **Upsert**: `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts:147-234` 657 700 - **Explicit Control**: `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts` 658 701 - **Repository Methods**: `src/modules/cards/domain/ICardRepository.ts:15-18` 659 702 660 703 ### Related Guides 704 + 661 705 - Domain-Driven Design patterns 662 706 - Repository pattern 663 707 - Event publishing
+1 -2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 172 172 } 173 173 174 174 // Save updated note card 175 - const saveNoteCardResult = 176 - await this.cardRepository.save(noteCard); 175 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 177 176 if (saveNoteCardResult.isErr()) { 178 177 return err( 179 178 AppError.UnexpectedError.create(saveNoteCardResult.error),
+11 -11
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
··· 76 76 const cardIdResult = CardId.createFromString(request.cardId); 77 77 if (cardIdResult.isErr()) { 78 78 return err( 79 - new ValidationError( 80 - `Invalid card ID: ${cardIdResult.error.message}`, 81 - ), 79 + new ValidationError(`Invalid card ID: ${cardIdResult.error.message}`), 82 80 ); 83 81 } 84 82 const cardId = cardIdResult.value; ··· 103 101 // Verify it's a URL card 104 102 if (!urlCard.isUrlCard) { 105 103 return err( 106 - new ValidationError('Card must be a URL card to update associations.'), 104 + new ValidationError( 105 + 'Card must be a URL card to update associations.', 106 + ), 107 107 ); 108 108 } 109 109 110 110 // Verify ownership 111 111 if (!urlCard.curatorId.equals(curatorId)) { 112 112 return err( 113 - new ValidationError('You do not have permission to update this card.'), 113 + new ValidationError( 114 + 'You do not have permission to update this card.', 115 + ), 114 116 ); 115 117 } 116 118 117 119 // Get the URL from the card for note operations 118 120 if (!urlCard.url) { 119 - return err( 120 - new ValidationError('URL card must have a URL property.'), 121 - ); 121 + return err(new ValidationError('URL card must have a URL property.')); 122 122 } 123 123 const url = urlCard.url; 124 124 ··· 152 152 } 153 153 154 154 // Save updated note card 155 - const saveNoteCardResult = 156 - await this.cardRepository.save(noteCard); 155 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 157 156 if (saveNoteCardResult.isErr()) { 158 157 return err( 159 158 AppError.UnexpectedError.create(saveNoteCardResult.error), ··· 284 283 ); 285 284 if (removeFromCollectionsResult.isErr()) { 286 285 if ( 287 - removeFromCollectionsResult.error instanceof AppError.UnexpectedError 286 + removeFromCollectionsResult.error instanceof 287 + AppError.UnexpectedError 288 288 ) { 289 289 return err(removeFromCollectionsResult.error); 290 290 }