This repository has no description
0

Configure Feed

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

update add url to library use case and create update url card associations use case

+1039 -34
+74 -34
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 16 16 import { URL } from '../../../domain/value-objects/URL'; 17 17 import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 18 18 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 19 + import { CardContent } from '../../../domain/value-objects/CardContent'; 19 20 20 21 export interface AddUrlToLibraryDTO { 21 22 url: string; ··· 143 144 144 145 let noteCard; 145 146 146 - // Create note card if note is provided 147 + // Handle note card creation or update if note is provided 147 148 if (request.note) { 148 - const noteCardInput: INoteCardInput = { 149 - type: CardTypeEnum.NOTE, 150 - text: request.note, 151 - parentCardId: urlCard.cardId.getStringValue(), 152 - url: request.url, 153 - }; 149 + // Check if note card already exists for this URL and curator 150 + const existingNoteCardResult = 151 + await this.cardRepository.findUsersNoteCardByUrl(url, curatorId); 152 + if (existingNoteCardResult.isErr()) { 153 + return err( 154 + AppError.UnexpectedError.create(existingNoteCardResult.error), 155 + ); 156 + } 157 + 158 + noteCard = existingNoteCardResult.value; 159 + 160 + if (noteCard) { 161 + // Update existing note card 162 + const newContentResult = CardContent.createNoteContent(request.note); 163 + if (newContentResult.isErr()) { 164 + return err(new ValidationError(newContentResult.error.message)); 165 + } 154 166 155 - const noteCardResult = CardFactory.create({ 156 - curatorId: request.curatorId, 157 - cardInput: noteCardInput, 158 - }); 167 + const updateContentResult = noteCard.updateContent( 168 + newContentResult.value, 169 + ); 170 + if (updateContentResult.isErr()) { 171 + return err(new ValidationError(updateContentResult.error.message)); 172 + } 173 + 174 + // Save updated note card 175 + const saveNoteCardResult = 176 + await this.cardRepository.save(noteCard); 177 + if (saveNoteCardResult.isErr()) { 178 + return err( 179 + AppError.UnexpectedError.create(saveNoteCardResult.error), 180 + ); 181 + } 182 + } else { 183 + // Create new note card 184 + const noteCardInput: INoteCardInput = { 185 + type: CardTypeEnum.NOTE, 186 + text: request.note, 187 + parentCardId: urlCard.cardId.getStringValue(), 188 + url: request.url, 189 + }; 190 + 191 + const noteCardResult = CardFactory.create({ 192 + curatorId: request.curatorId, 193 + cardInput: noteCardInput, 194 + }); 159 195 160 - if (noteCardResult.isErr()) { 161 - return err(new ValidationError(noteCardResult.error.message)); 162 - } 196 + if (noteCardResult.isErr()) { 197 + return err(new ValidationError(noteCardResult.error.message)); 198 + } 163 199 164 - noteCard = noteCardResult.value; 200 + noteCard = noteCardResult.value; 165 201 166 - // Save note card 167 - const saveNoteCardResult = await this.cardRepository.save(noteCard); 168 - if (saveNoteCardResult.isErr()) { 169 - return err(AppError.UnexpectedError.create(saveNoteCardResult.error)); 170 - } 202 + // Save note card 203 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 204 + if (saveNoteCardResult.isErr()) { 205 + return err( 206 + AppError.UnexpectedError.create(saveNoteCardResult.error), 207 + ); 208 + } 171 209 172 - // Add note card to library using domain service 173 - const addNoteCardToLibraryResult = 174 - await this.cardLibraryService.addCardToLibrary(noteCard, curatorId); 175 - if (addNoteCardToLibraryResult.isErr()) { 176 - if ( 177 - addNoteCardToLibraryResult.error instanceof AppError.UnexpectedError 178 - ) { 179 - return err(addNoteCardToLibraryResult.error); 210 + // Add note card to library using domain service 211 + const addNoteCardToLibraryResult = 212 + await this.cardLibraryService.addCardToLibrary(noteCard, curatorId); 213 + if (addNoteCardToLibraryResult.isErr()) { 214 + if ( 215 + addNoteCardToLibraryResult.error instanceof 216 + AppError.UnexpectedError 217 + ) { 218 + return err(addNoteCardToLibraryResult.error); 219 + } 220 + return err( 221 + new ValidationError(addNoteCardToLibraryResult.error.message), 222 + ); 180 223 } 181 - return err( 182 - new ValidationError(addNoteCardToLibraryResult.error.message), 183 - ); 224 + 225 + // Update noteCard reference to the one returned by the service 226 + noteCard = addNoteCardToLibraryResult.value; 184 227 } 185 - 186 - // Update noteCard reference to the one returned by the service 187 - noteCard = addNoteCardToLibraryResult.value; 188 228 } 189 229 190 230 // Handle collection additions if specified
+298
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 3 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4 + import { AppError } from '../../../../../shared/core/AppError'; 5 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 6 + import { ICardRepository } from '../../../domain/ICardRepository'; 7 + import { INoteCardInput } from '../../../domain/CardFactory'; 8 + import { CollectionId } from '../../../domain/value-objects/CollectionId'; 9 + import { CuratorId } from '../../../domain/value-objects/CuratorId'; 10 + import { CardTypeEnum } from '../../../domain/value-objects/CardType'; 11 + import { URL } from '../../../domain/value-objects/URL'; 12 + import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 13 + import { CardContent } from '../../../domain/value-objects/CardContent'; 14 + import { CardFactory } from '../../../domain/CardFactory'; 15 + import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 16 + 17 + export interface UpdateUrlCardAssociationsDTO { 18 + url: string; 19 + curatorId: string; 20 + note?: string; 21 + addToCollections?: string[]; 22 + removeFromCollections?: string[]; 23 + } 24 + 25 + export interface UpdateUrlCardAssociationsResponseDTO { 26 + urlCardId: string; 27 + noteCardId?: string; 28 + addedToCollections: string[]; 29 + removedFromCollections: string[]; 30 + } 31 + 32 + export class ValidationError extends UseCaseError { 33 + constructor(message: string) { 34 + super(message); 35 + } 36 + } 37 + 38 + export class UpdateUrlCardAssociationsUseCase extends BaseUseCase< 39 + UpdateUrlCardAssociationsDTO, 40 + Result< 41 + UpdateUrlCardAssociationsResponseDTO, 42 + ValidationError | AppError.UnexpectedError 43 + > 44 + > { 45 + constructor( 46 + private cardRepository: ICardRepository, 47 + private cardLibraryService: CardLibraryService, 48 + private cardCollectionService: CardCollectionService, 49 + eventPublisher: IEventPublisher, 50 + ) { 51 + super(eventPublisher); 52 + } 53 + 54 + async execute( 55 + request: UpdateUrlCardAssociationsDTO, 56 + ): Promise< 57 + Result< 58 + UpdateUrlCardAssociationsResponseDTO, 59 + ValidationError | AppError.UnexpectedError 60 + > 61 + > { 62 + try { 63 + // Validate and create CuratorId 64 + const curatorIdResult = CuratorId.create(request.curatorId); 65 + if (curatorIdResult.isErr()) { 66 + return err( 67 + new ValidationError( 68 + `Invalid curator ID: ${curatorIdResult.error.message}`, 69 + ), 70 + ); 71 + } 72 + const curatorId = curatorIdResult.value; 73 + 74 + // Validate URL 75 + const urlResult = URL.create(request.url); 76 + if (urlResult.isErr()) { 77 + return err( 78 + new ValidationError(`Invalid URL: ${urlResult.error.message}`), 79 + ); 80 + } 81 + const url = urlResult.value; 82 + 83 + // Find the URL card - it must already exist 84 + const existingUrlCardResult = 85 + await this.cardRepository.findUsersUrlCardByUrl(url, curatorId); 86 + if (existingUrlCardResult.isErr()) { 87 + return err( 88 + AppError.UnexpectedError.create(existingUrlCardResult.error), 89 + ); 90 + } 91 + 92 + const urlCard = existingUrlCardResult.value; 93 + if (!urlCard) { 94 + return err( 95 + new ValidationError( 96 + 'URL card not found. Please add the URL to your library first.', 97 + ), 98 + ); 99 + } 100 + 101 + let noteCard; 102 + 103 + // Handle note updates/creation 104 + if (request.note !== undefined) { 105 + // Check if note card already exists 106 + const existingNoteCardResult = 107 + await this.cardRepository.findUsersNoteCardByUrl(url, curatorId); 108 + if (existingNoteCardResult.isErr()) { 109 + return err( 110 + AppError.UnexpectedError.create(existingNoteCardResult.error), 111 + ); 112 + } 113 + 114 + noteCard = existingNoteCardResult.value; 115 + 116 + if (noteCard) { 117 + // Update existing note card 118 + const newContentResult = CardContent.createNoteContent(request.note); 119 + if (newContentResult.isErr()) { 120 + return err(new ValidationError(newContentResult.error.message)); 121 + } 122 + 123 + const updateContentResult = noteCard.updateContent( 124 + newContentResult.value, 125 + ); 126 + if (updateContentResult.isErr()) { 127 + return err(new ValidationError(updateContentResult.error.message)); 128 + } 129 + 130 + // Save updated note card 131 + const saveNoteCardResult = 132 + await this.cardRepository.save(noteCard); 133 + if (saveNoteCardResult.isErr()) { 134 + return err( 135 + AppError.UnexpectedError.create(saveNoteCardResult.error), 136 + ); 137 + } 138 + } else { 139 + // Create new note card 140 + const noteCardInput: INoteCardInput = { 141 + type: CardTypeEnum.NOTE, 142 + text: request.note, 143 + parentCardId: urlCard.cardId.getStringValue(), 144 + url: request.url, 145 + }; 146 + 147 + const noteCardResult = CardFactory.create({ 148 + curatorId: request.curatorId, 149 + cardInput: noteCardInput, 150 + }); 151 + 152 + if (noteCardResult.isErr()) { 153 + return err(new ValidationError(noteCardResult.error.message)); 154 + } 155 + 156 + noteCard = noteCardResult.value; 157 + 158 + // Save note card 159 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 160 + if (saveNoteCardResult.isErr()) { 161 + return err( 162 + AppError.UnexpectedError.create(saveNoteCardResult.error), 163 + ); 164 + } 165 + 166 + // Add note card to library using domain service 167 + const addNoteCardToLibraryResult = 168 + await this.cardLibraryService.addCardToLibrary(noteCard, curatorId); 169 + if (addNoteCardToLibraryResult.isErr()) { 170 + if ( 171 + addNoteCardToLibraryResult.error instanceof 172 + AppError.UnexpectedError 173 + ) { 174 + return err(addNoteCardToLibraryResult.error); 175 + } 176 + return err( 177 + new ValidationError(addNoteCardToLibraryResult.error.message), 178 + ); 179 + } 180 + 181 + // Update noteCard reference to the one returned by the service 182 + noteCard = addNoteCardToLibraryResult.value; 183 + } 184 + } 185 + 186 + const addedToCollections: string[] = []; 187 + const removedFromCollections: string[] = []; 188 + 189 + // Handle adding to collections 190 + if (request.addToCollections && request.addToCollections.length > 0) { 191 + const collectionIds: CollectionId[] = []; 192 + for (const collectionIdStr of request.addToCollections) { 193 + const collectionIdResult = 194 + CollectionId.createFromString(collectionIdStr); 195 + if (collectionIdResult.isErr()) { 196 + return err( 197 + new ValidationError( 198 + `Invalid collection ID: ${collectionIdResult.error.message}`, 199 + ), 200 + ); 201 + } 202 + collectionIds.push(collectionIdResult.value); 203 + } 204 + 205 + const addToCollectionsResult = 206 + await this.cardCollectionService.addCardToCollections( 207 + urlCard, 208 + collectionIds, 209 + curatorId, 210 + ); 211 + if (addToCollectionsResult.isErr()) { 212 + if ( 213 + addToCollectionsResult.error instanceof AppError.UnexpectedError 214 + ) { 215 + return err(addToCollectionsResult.error); 216 + } 217 + return err(new ValidationError(addToCollectionsResult.error.message)); 218 + } 219 + 220 + // Publish events for all affected collections 221 + const updatedCollections = addToCollectionsResult.value; 222 + for (const collection of updatedCollections) { 223 + const publishResult = 224 + await this.publishEventsForAggregate(collection); 225 + if (publishResult.isErr()) { 226 + console.error( 227 + 'Failed to publish events for collection:', 228 + publishResult.error, 229 + ); 230 + // Don't fail the operation if event publishing fails 231 + } 232 + addedToCollections.push(collection.collectionId.getStringValue()); 233 + } 234 + } 235 + 236 + // Handle removing from collections 237 + if ( 238 + request.removeFromCollections && 239 + request.removeFromCollections.length > 0 240 + ) { 241 + const collectionIds: CollectionId[] = []; 242 + for (const collectionIdStr of request.removeFromCollections) { 243 + const collectionIdResult = 244 + CollectionId.createFromString(collectionIdStr); 245 + if (collectionIdResult.isErr()) { 246 + return err( 247 + new ValidationError( 248 + `Invalid collection ID: ${collectionIdResult.error.message}`, 249 + ), 250 + ); 251 + } 252 + collectionIds.push(collectionIdResult.value); 253 + } 254 + 255 + const removeFromCollectionsResult = 256 + await this.cardCollectionService.removeCardFromCollections( 257 + urlCard, 258 + collectionIds, 259 + curatorId, 260 + ); 261 + if (removeFromCollectionsResult.isErr()) { 262 + if ( 263 + removeFromCollectionsResult.error instanceof AppError.UnexpectedError 264 + ) { 265 + return err(removeFromCollectionsResult.error); 266 + } 267 + return err( 268 + new ValidationError(removeFromCollectionsResult.error.message), 269 + ); 270 + } 271 + 272 + // Publish events for all affected collections 273 + const updatedCollections = removeFromCollectionsResult.value; 274 + for (const collection of updatedCollections) { 275 + const publishResult = 276 + await this.publishEventsForAggregate(collection); 277 + if (publishResult.isErr()) { 278 + console.error( 279 + 'Failed to publish events for collection:', 280 + publishResult.error, 281 + ); 282 + // Don't fail the operation if event publishing fails 283 + } 284 + removedFromCollections.push(collection.collectionId.getStringValue()); 285 + } 286 + } 287 + 288 + return ok({ 289 + urlCardId: urlCard.cardId.getStringValue(), 290 + noteCardId: noteCard?.cardId.getStringValue(), 291 + addedToCollections, 292 + removedFromCollections, 293 + }); 294 + } catch (error) { 295 + return err(AppError.UnexpectedError.create(error)); 296 + } 297 + } 298 + }
+4
src/modules/cards/domain/ICardRepository.ts
··· 12 12 url: URL, 13 13 curatorId: CuratorId, 14 14 ): Promise<Result<Card | null>>; 15 + findUsersNoteCardByUrl( 16 + url: URL, 17 + curatorId: CuratorId, 18 + ): Promise<Result<Card | null>>; 15 19 }
+43
src/modules/cards/infrastructure/http/controllers/UpdateUrlCardAssociationsController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { UpdateUrlCardAssociationsUseCase } from '../../../application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class UpdateUrlCardAssociationsController extends Controller { 7 + constructor( 8 + private updateUrlCardAssociationsUseCase: UpdateUrlCardAssociationsUseCase, 9 + ) { 10 + super(); 11 + } 12 + 13 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 14 + try { 15 + const { url, note, addToCollections, removeFromCollections } = req.body; 16 + const curatorId = req.did; 17 + 18 + if (!curatorId) { 19 + return this.unauthorized(res); 20 + } 21 + 22 + if (!url) { 23 + return this.badRequest(res, 'URL is required'); 24 + } 25 + 26 + const result = await this.updateUrlCardAssociationsUseCase.execute({ 27 + url, 28 + curatorId, 29 + note, 30 + addToCollections, 31 + removeFromCollections, 32 + }); 33 + 34 + if (result.isErr()) { 35 + return this.fail(res, result.error); 36 + } 37 + 38 + return this.ok(res, result.value); 39 + } catch (error: any) { 40 + return this.fail(res, error); 41 + } 42 + } 43 + }
+9
src/modules/cards/infrastructure/http/routes/cardRoutes.ts
··· 3 3 import { AddCardToLibraryController } from '../controllers/AddCardToLibraryController'; 4 4 import { AddCardToCollectionController } from '../controllers/AddCardToCollectionController'; 5 5 import { UpdateNoteCardController } from '../controllers/UpdateNoteCardController'; 6 + import { UpdateUrlCardAssociationsController } from '../controllers/UpdateUrlCardAssociationsController'; 6 7 import { RemoveCardFromLibraryController } from '../controllers/RemoveCardFromLibraryController'; 7 8 import { RemoveCardFromCollectionController } from '../controllers/RemoveCardFromCollectionController'; 8 9 import { GetUrlMetadataController } from '../controllers/GetUrlMetadataController'; ··· 21 22 addCardToLibraryController: AddCardToLibraryController, 22 23 addCardToCollectionController: AddCardToCollectionController, 23 24 updateNoteCardController: UpdateNoteCardController, 25 + updateUrlCardAssociationsController: UpdateUrlCardAssociationsController, 24 26 removeCardFromLibraryController: RemoveCardFromLibraryController, 25 27 removeCardFromCollectionController: RemoveCardFromCollectionController, 26 28 getUrlMetadataController: GetUrlMetadataController, ··· 102 104 '/:cardId/note', 103 105 authMiddleware.ensureAuthenticated(), 104 106 (req, res) => updateNoteCardController.execute(req, res), 107 + ); 108 + 109 + // PUT /api/cards/url/associations - Update URL card associations (note, collections) 110 + router.put( 111 + '/url/associations', 112 + authMiddleware.ensureAuthenticated(), 113 + (req, res) => updateUrlCardAssociationsController.execute(req, res), 105 114 ); 106 115 107 116 // DELETE /api/cards/:cardId/library - Remove card from user's library
+3
src/modules/cards/infrastructure/http/routes/index.ts
··· 5 5 import { AddCardToLibraryController } from '../controllers/AddCardToLibraryController'; 6 6 import { AddCardToCollectionController } from '../controllers/AddCardToCollectionController'; 7 7 import { UpdateNoteCardController } from '../controllers/UpdateNoteCardController'; 8 + import { UpdateUrlCardAssociationsController } from '../controllers/UpdateUrlCardAssociationsController'; 8 9 import { RemoveCardFromLibraryController } from '../controllers/RemoveCardFromLibraryController'; 9 10 import { RemoveCardFromCollectionController } from '../controllers/RemoveCardFromCollectionController'; 10 11 import { GetUrlMetadataController } from '../controllers/GetUrlMetadataController'; ··· 32 33 addCardToLibraryController: AddCardToLibraryController, 33 34 addCardToCollectionController: AddCardToCollectionController, 34 35 updateNoteCardController: UpdateNoteCardController, 36 + updateUrlCardAssociationsController: UpdateUrlCardAssociationsController, 35 37 removeCardFromLibraryController: RemoveCardFromLibraryController, 36 38 removeCardFromCollectionController: RemoveCardFromCollectionController, 37 39 getUrlMetadataController: GetUrlMetadataController, ··· 63 65 addCardToLibraryController, 64 66 addCardToCollectionController, 65 67 updateNoteCardController, 68 + updateUrlCardAssociationsController, 66 69 removeCardFromLibraryController, 67 70 removeCardFromCollectionController, 68 71 getUrlMetadataController,
+100
src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts
··· 359 359 return err(error as Error); 360 360 } 361 361 } 362 + 363 + async findUsersNoteCardByUrl( 364 + url: URL, 365 + curatorId: CuratorId, 366 + ): Promise<Result<Card | null>> { 367 + try { 368 + const urlValue = url.value; 369 + 370 + const cardResult = await this.db 371 + .select() 372 + .from(cards) 373 + .where( 374 + and( 375 + eq(cards.url, urlValue), 376 + eq(cards.type, 'NOTE'), 377 + eq(cards.authorId, curatorId.value), 378 + ), 379 + ) 380 + .limit(1); 381 + 382 + if (cardResult.length === 0) { 383 + return ok(null); 384 + } 385 + 386 + const result = cardResult[0]; 387 + if (!result) { 388 + return ok(null); 389 + } 390 + 391 + // Get library memberships for this card with published record details 392 + const membershipResults = await this.db 393 + .select({ 394 + userId: libraryMemberships.userId, 395 + addedAt: libraryMemberships.addedAt, 396 + publishedRecordUri: publishedRecords.uri, 397 + publishedRecordCid: publishedRecords.cid, 398 + }) 399 + .from(libraryMemberships) 400 + .leftJoin( 401 + publishedRecords, 402 + eq(libraryMemberships.publishedRecordId, publishedRecords.id), 403 + ) 404 + .where(eq(libraryMemberships.cardId, result.id)); 405 + 406 + // Get published record if it exists 407 + let publishedRecord = null; 408 + if (result.publishedRecordId) { 409 + const publishedRecordResult = await this.db 410 + .select({ 411 + uri: publishedRecords.uri, 412 + cid: publishedRecords.cid, 413 + }) 414 + .from(publishedRecords) 415 + .where(eq(publishedRecords.id, result.publishedRecordId)) 416 + .limit(1); 417 + 418 + if (publishedRecordResult.length > 0) { 419 + publishedRecord = publishedRecordResult[0]; 420 + } 421 + } 422 + 423 + const cardDTO: CardDTO = { 424 + id: result.id, 425 + curatorId: result.authorId, 426 + type: result.type, 427 + contentData: result.contentData, 428 + url: result.url || undefined, 429 + parentCardId: result.parentCardId || undefined, 430 + publishedRecordId: publishedRecord 431 + ? { 432 + uri: publishedRecord.uri, 433 + cid: publishedRecord.cid, 434 + } 435 + : undefined, 436 + libraryCount: result.libraryCount ?? 0, 437 + libraryMemberships: membershipResults.map((membership) => ({ 438 + userId: membership.userId, 439 + addedAt: membership.addedAt, 440 + publishedRecordId: 441 + membership.publishedRecordUri && membership.publishedRecordCid 442 + ? { 443 + uri: membership.publishedRecordUri, 444 + cid: membership.publishedRecordCid, 445 + } 446 + : undefined, 447 + })), 448 + createdAt: result.createdAt, 449 + updatedAt: result.updatedAt, 450 + }; 451 + 452 + const domainResult = CardMapper.toDomain(cardDTO); 453 + if (domainResult.isErr()) { 454 + return err(domainResult.error); 455 + } 456 + 457 + return ok(domainResult.value); 458 + } catch (error) { 459 + return err(error as Error); 460 + } 461 + } 362 462 }
+52
src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts
··· 262 262 expect(urlCard?.content.type).toBe(CardTypeEnum.URL); 263 263 expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true); 264 264 }); 265 + 266 + it('should update existing note card when URL already exists with a note', async () => { 267 + const url = 'https://example.com/existing'; 268 + 269 + // First request creates URL card with note 270 + const firstRequest = { 271 + url, 272 + note: 'Original note', 273 + curatorId: curatorId.value, 274 + }; 275 + 276 + const firstResult = await useCase.execute(firstRequest); 277 + expect(firstResult.isOk()).toBe(true); 278 + const firstResponse = firstResult.unwrap(); 279 + expect(firstResponse.noteCardId).toBeDefined(); 280 + 281 + // Get the original note card 282 + const cardsAfterFirst = cardRepository.getAllCards(); 283 + const originalNoteCard = cardsAfterFirst.find( 284 + (card) => card.content.type === CardTypeEnum.NOTE, 285 + ); 286 + expect(originalNoteCard).toBeDefined(); 287 + expect(originalNoteCard?.content.noteContent?.text).toBe('Original note'); 288 + 289 + // Second request updates the note 290 + const secondRequest = { 291 + url, 292 + note: 'Updated note', 293 + curatorId: curatorId.value, 294 + }; 295 + 296 + const secondResult = await useCase.execute(secondRequest); 297 + expect(secondResult.isOk()).toBe(true); 298 + const secondResponse = secondResult.unwrap(); 299 + 300 + // Should still have the same note card ID 301 + expect(secondResponse.noteCardId).toBe(firstResponse.noteCardId); 302 + 303 + // Should still have 2 cards (URL + Note) 304 + const savedCards = cardRepository.getAllCards(); 305 + expect(savedCards).toHaveLength(2); 306 + 307 + // Verify note was updated 308 + const updatedNoteCard = savedCards.find( 309 + (card) => card.content.type === CardTypeEnum.NOTE, 310 + ); 311 + expect(updatedNoteCard).toBeDefined(); 312 + expect(updatedNoteCard?.content.noteContent?.text).toBe('Updated note'); 313 + expect(updatedNoteCard?.cardId.getStringValue()).toBe( 314 + firstResponse.noteCardId, 315 + ); 316 + }); 265 317 }); 266 318 267 319 describe('Collection handling', () => {
+424
src/modules/cards/tests/application/UpdateUrlCardAssociationsUseCase.test.ts
··· 1 + import { UpdateUrlCardAssociationsUseCase } from '../../application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 2 + import { AddUrlToLibraryUseCase } from '../../application/useCases/commands/AddUrlToLibraryUseCase'; 3 + import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 4 + import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5 + import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 6 + import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 7 + import { FakeMetadataService } from '../utils/FakeMetadataService'; 8 + import { CardLibraryService } from '../../domain/services/CardLibraryService'; 9 + import { CardCollectionService } from '../../domain/services/CardCollectionService'; 10 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 11 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 12 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 13 + import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 14 + 15 + describe('UpdateUrlCardAssociationsUseCase', () => { 16 + let useCase: UpdateUrlCardAssociationsUseCase; 17 + let addUrlToLibraryUseCase: AddUrlToLibraryUseCase; 18 + let cardRepository: InMemoryCardRepository; 19 + let collectionRepository: InMemoryCollectionRepository; 20 + let cardPublisher: FakeCardPublisher; 21 + let collectionPublisher: FakeCollectionPublisher; 22 + let metadataService: FakeMetadataService; 23 + let cardLibraryService: CardLibraryService; 24 + let cardCollectionService: CardCollectionService; 25 + let eventPublisher: FakeEventPublisher; 26 + let curatorId: CuratorId; 27 + 28 + beforeEach(() => { 29 + cardRepository = new InMemoryCardRepository(); 30 + collectionRepository = new InMemoryCollectionRepository(); 31 + cardPublisher = new FakeCardPublisher(); 32 + collectionPublisher = new FakeCollectionPublisher(); 33 + metadataService = new FakeMetadataService(); 34 + eventPublisher = new FakeEventPublisher(); 35 + 36 + cardLibraryService = new CardLibraryService( 37 + cardRepository, 38 + cardPublisher, 39 + collectionRepository, 40 + cardCollectionService, 41 + ); 42 + cardCollectionService = new CardCollectionService( 43 + collectionRepository, 44 + collectionPublisher, 45 + ); 46 + 47 + useCase = new UpdateUrlCardAssociationsUseCase( 48 + cardRepository, 49 + cardLibraryService, 50 + cardCollectionService, 51 + eventPublisher, 52 + ); 53 + 54 + addUrlToLibraryUseCase = new AddUrlToLibraryUseCase( 55 + cardRepository, 56 + metadataService, 57 + cardLibraryService, 58 + cardCollectionService, 59 + eventPublisher, 60 + ); 61 + 62 + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 63 + }); 64 + 65 + afterEach(() => { 66 + cardRepository.clear(); 67 + collectionRepository.clear(); 68 + cardPublisher.clear(); 69 + collectionPublisher.clear(); 70 + metadataService.clear(); 71 + eventPublisher.clear(); 72 + }); 73 + 74 + describe('Note management', () => { 75 + it('should create a note for an existing URL card', async () => { 76 + const url = 'https://example.com/article'; 77 + 78 + // First, add the URL to the library 79 + const addResult = await addUrlToLibraryUseCase.execute({ 80 + url, 81 + curatorId: curatorId.value, 82 + }); 83 + expect(addResult.isOk()).toBe(true); 84 + 85 + // Now create a note for it 86 + const updateRequest = { 87 + url, 88 + curatorId: curatorId.value, 89 + note: 'This is my note', 90 + }; 91 + 92 + const result = await useCase.execute(updateRequest); 93 + 94 + expect(result.isOk()).toBe(true); 95 + const response = result.unwrap(); 96 + expect(response.noteCardId).toBeDefined(); 97 + 98 + // Verify note card was created 99 + const savedCards = cardRepository.getAllCards(); 100 + const noteCard = savedCards.find( 101 + (card) => card.content.type === CardTypeEnum.NOTE, 102 + ); 103 + expect(noteCard).toBeDefined(); 104 + expect(noteCard?.content.noteContent?.text).toBe('This is my note'); 105 + }); 106 + 107 + it('should update an existing note for a URL card', async () => { 108 + const url = 'https://example.com/article'; 109 + 110 + // First, add the URL with a note 111 + const addResult = await addUrlToLibraryUseCase.execute({ 112 + url, 113 + note: 'Original note', 114 + curatorId: curatorId.value, 115 + }); 116 + expect(addResult.isOk()).toBe(true); 117 + const addResponse = addResult.unwrap(); 118 + 119 + // Now update the note 120 + const updateRequest = { 121 + url, 122 + curatorId: curatorId.value, 123 + note: 'Updated note', 124 + }; 125 + 126 + const result = await useCase.execute(updateRequest); 127 + 128 + expect(result.isOk()).toBe(true); 129 + const response = result.unwrap(); 130 + expect(response.noteCardId).toBe(addResponse.noteCardId); 131 + 132 + // Verify note was updated 133 + const savedCards = cardRepository.getAllCards(); 134 + const noteCard = savedCards.find( 135 + (card) => card.content.type === CardTypeEnum.NOTE, 136 + ); 137 + expect(noteCard).toBeDefined(); 138 + expect(noteCard?.content.noteContent?.text).toBe('Updated note'); 139 + }); 140 + 141 + it('should fail if URL card does not exist', async () => { 142 + const request = { 143 + url: 'https://example.com/nonexistent', 144 + curatorId: curatorId.value, 145 + note: 'This should fail', 146 + }; 147 + 148 + const result = await useCase.execute(request); 149 + 150 + expect(result.isErr()).toBe(true); 151 + if (result.isErr()) { 152 + expect(result.error.message).toContain( 153 + 'URL card not found. Please add the URL to your library first.', 154 + ); 155 + } 156 + }); 157 + }); 158 + 159 + describe('Collection management', () => { 160 + it('should add URL card to collections', async () => { 161 + const url = 'https://example.com/article'; 162 + 163 + // Create collections 164 + const collection1 = new CollectionBuilder() 165 + .withAuthorId(curatorId.value) 166 + .withName('Collection 1') 167 + .build(); 168 + const collection2 = new CollectionBuilder() 169 + .withAuthorId(curatorId.value) 170 + .withName('Collection 2') 171 + .build(); 172 + 173 + if (collection1 instanceof Error || collection2 instanceof Error) { 174 + throw new Error('Failed to create collections'); 175 + } 176 + 177 + await collectionRepository.save(collection1); 178 + await collectionRepository.save(collection2); 179 + 180 + // Add URL to library 181 + const addResult = await addUrlToLibraryUseCase.execute({ 182 + url, 183 + curatorId: curatorId.value, 184 + }); 185 + expect(addResult.isOk()).toBe(true); 186 + 187 + // Add to collections 188 + const updateRequest = { 189 + url, 190 + curatorId: curatorId.value, 191 + addToCollections: [ 192 + collection1.collectionId.getStringValue(), 193 + collection2.collectionId.getStringValue(), 194 + ], 195 + }; 196 + 197 + const result = await useCase.execute(updateRequest); 198 + 199 + expect(result.isOk()).toBe(true); 200 + const response = result.unwrap(); 201 + expect(response.addedToCollections).toHaveLength(2); 202 + expect(response.addedToCollections).toContain( 203 + collection1.collectionId.getStringValue(), 204 + ); 205 + expect(response.addedToCollections).toContain( 206 + collection2.collectionId.getStringValue(), 207 + ); 208 + }); 209 + 210 + it('should remove URL card from collections', async () => { 211 + const url = 'https://example.com/article'; 212 + 213 + // Create collection 214 + const collection = new CollectionBuilder() 215 + .withAuthorId(curatorId.value) 216 + .withName('Test Collection') 217 + .build(); 218 + 219 + if (collection instanceof Error) { 220 + throw new Error('Failed to create collection'); 221 + } 222 + 223 + await collectionRepository.save(collection); 224 + 225 + // Add URL to library and collection 226 + const addResult = await addUrlToLibraryUseCase.execute({ 227 + url, 228 + collectionIds: [collection.collectionId.getStringValue()], 229 + curatorId: curatorId.value, 230 + }); 231 + expect(addResult.isOk()).toBe(true); 232 + 233 + // Remove from collection 234 + const updateRequest = { 235 + url, 236 + curatorId: curatorId.value, 237 + removeFromCollections: [collection.collectionId.getStringValue()], 238 + }; 239 + 240 + const result = await useCase.execute(updateRequest); 241 + 242 + expect(result.isOk()).toBe(true); 243 + const response = result.unwrap(); 244 + expect(response.removedFromCollections).toHaveLength(1); 245 + expect(response.removedFromCollections).toContain( 246 + collection.collectionId.getStringValue(), 247 + ); 248 + }); 249 + 250 + it('should add and remove from different collections in same request', async () => { 251 + const url = 'https://example.com/article'; 252 + 253 + // Create collections 254 + const collection1 = new CollectionBuilder() 255 + .withAuthorId(curatorId.value) 256 + .withName('Collection 1') 257 + .build(); 258 + const collection2 = new CollectionBuilder() 259 + .withAuthorId(curatorId.value) 260 + .withName('Collection 2') 261 + .build(); 262 + const collection3 = new CollectionBuilder() 263 + .withAuthorId(curatorId.value) 264 + .withName('Collection 3') 265 + .build(); 266 + 267 + if ( 268 + collection1 instanceof Error || 269 + collection2 instanceof Error || 270 + collection3 instanceof Error 271 + ) { 272 + throw new Error('Failed to create collections'); 273 + } 274 + 275 + await collectionRepository.save(collection1); 276 + await collectionRepository.save(collection2); 277 + await collectionRepository.save(collection3); 278 + 279 + // Add URL to library with collection1 280 + const addResult = await addUrlToLibraryUseCase.execute({ 281 + url, 282 + collectionIds: [collection1.collectionId.getStringValue()], 283 + curatorId: curatorId.value, 284 + }); 285 + expect(addResult.isOk()).toBe(true); 286 + 287 + // Add to collection2 and collection3, remove from collection1 288 + const updateRequest = { 289 + url, 290 + curatorId: curatorId.value, 291 + addToCollections: [ 292 + collection2.collectionId.getStringValue(), 293 + collection3.collectionId.getStringValue(), 294 + ], 295 + removeFromCollections: [collection1.collectionId.getStringValue()], 296 + }; 297 + 298 + const result = await useCase.execute(updateRequest); 299 + 300 + expect(result.isOk()).toBe(true); 301 + const response = result.unwrap(); 302 + expect(response.addedToCollections).toHaveLength(2); 303 + expect(response.removedFromCollections).toHaveLength(1); 304 + expect(response.addedToCollections).toContain( 305 + collection2.collectionId.getStringValue(), 306 + ); 307 + expect(response.addedToCollections).toContain( 308 + collection3.collectionId.getStringValue(), 309 + ); 310 + expect(response.removedFromCollections).toContain( 311 + collection1.collectionId.getStringValue(), 312 + ); 313 + }); 314 + }); 315 + 316 + describe('Combined operations', () => { 317 + it('should update note and manage collections in same request', async () => { 318 + const url = 'https://example.com/article'; 319 + 320 + // Create collection 321 + const collection = new CollectionBuilder() 322 + .withAuthorId(curatorId.value) 323 + .withName('Test Collection') 324 + .build(); 325 + 326 + if (collection instanceof Error) { 327 + throw new Error('Failed to create collection'); 328 + } 329 + 330 + await collectionRepository.save(collection); 331 + 332 + // Add URL to library 333 + const addResult = await addUrlToLibraryUseCase.execute({ 334 + url, 335 + curatorId: curatorId.value, 336 + }); 337 + expect(addResult.isOk()).toBe(true); 338 + 339 + // Update note and add to collection 340 + const updateRequest = { 341 + url, 342 + curatorId: curatorId.value, 343 + note: 'My note about this article', 344 + addToCollections: [collection.collectionId.getStringValue()], 345 + }; 346 + 347 + const result = await useCase.execute(updateRequest); 348 + 349 + expect(result.isOk()).toBe(true); 350 + const response = result.unwrap(); 351 + expect(response.noteCardId).toBeDefined(); 352 + expect(response.addedToCollections).toHaveLength(1); 353 + expect(response.addedToCollections).toContain( 354 + collection.collectionId.getStringValue(), 355 + ); 356 + 357 + // Verify note was created 358 + const savedCards = cardRepository.getAllCards(); 359 + const noteCard = savedCards.find( 360 + (card) => card.content.type === CardTypeEnum.NOTE, 361 + ); 362 + expect(noteCard).toBeDefined(); 363 + expect(noteCard?.content.noteContent?.text).toBe( 364 + 'My note about this article', 365 + ); 366 + }); 367 + }); 368 + 369 + describe('Validation', () => { 370 + it('should fail with invalid URL', async () => { 371 + const request = { 372 + url: 'not-a-valid-url', 373 + curatorId: curatorId.value, 374 + note: 'This should fail', 375 + }; 376 + 377 + const result = await useCase.execute(request); 378 + 379 + expect(result.isErr()).toBe(true); 380 + if (result.isErr()) { 381 + expect(result.error.message).toContain('Invalid URL'); 382 + } 383 + }); 384 + 385 + it('should fail with invalid curator ID', async () => { 386 + const request = { 387 + url: 'https://example.com/article', 388 + curatorId: 'invalid-curator-id', 389 + note: 'This should fail', 390 + }; 391 + 392 + const result = await useCase.execute(request); 393 + 394 + expect(result.isErr()).toBe(true); 395 + if (result.isErr()) { 396 + expect(result.error.message).toContain('Invalid curator ID'); 397 + } 398 + }); 399 + 400 + it('should fail with invalid collection ID', async () => { 401 + const url = 'https://example.com/article'; 402 + 403 + // Add URL to library 404 + const addResult = await addUrlToLibraryUseCase.execute({ 405 + url, 406 + curatorId: curatorId.value, 407 + }); 408 + expect(addResult.isOk()).toBe(true); 409 + 410 + const request = { 411 + url, 412 + curatorId: curatorId.value, 413 + addToCollections: ['invalid-collection-id'], 414 + }; 415 + 416 + const result = await useCase.execute(request); 417 + 418 + expect(result.isErr()).toBe(true); 419 + if (result.isErr()) { 420 + expect(result.error.message).toContain('Collection not found'); 421 + } 422 + }); 423 + }); 424 + });
+17
src/modules/cards/tests/utils/InMemoryCardRepository.ts
··· 64 64 } 65 65 } 66 66 67 + async findUsersNoteCardByUrl( 68 + url: URL, 69 + curatorId: CuratorId, 70 + ): Promise<Result<Card | null>> { 71 + try { 72 + const card = Array.from(this.cards.values()).find( 73 + (card) => 74 + card.type.value === 'NOTE' && 75 + card.url?.value === url.value && 76 + card.props.curatorId.equals(curatorId), 77 + ); 78 + return ok(card ? this.clone(card) : null); 79 + } catch (error) { 80 + return err(error as Error); 81 + } 82 + } 83 + 67 84 async save(card: Card): Promise<Result<void>> { 68 85 if (this.shouldFailSave || this.shouldFail) { 69 86 return err(new Error('Simulated save failure'));
+1
src/shared/infrastructure/http/app.ts
··· 60 60 controllers.addCardToLibraryController, 61 61 controllers.addCardToCollectionController, 62 62 controllers.updateNoteCardController, 63 + controllers.updateUrlCardAssociationsController, 63 64 controllers.removeCardFromLibraryController, 64 65 controllers.removeCardFromCollectionController, 65 66 controllers.getUrlMetadataController,
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 5 5 import { AddCardToLibraryController } from '../../../../modules/cards/infrastructure/http/controllers/AddCardToLibraryController'; 6 6 import { AddCardToCollectionController } from '../../../../modules/cards/infrastructure/http/controllers/AddCardToCollectionController'; 7 7 import { UpdateNoteCardController } from '../../../../modules/cards/infrastructure/http/controllers/UpdateNoteCardController'; 8 + import { UpdateUrlCardAssociationsController } from '../../../../modules/cards/infrastructure/http/controllers/UpdateUrlCardAssociationsController'; 8 9 import { RemoveCardFromLibraryController } from '../../../../modules/cards/infrastructure/http/controllers/RemoveCardFromLibraryController'; 9 10 import { RemoveCardFromCollectionController } from '../../../../modules/cards/infrastructure/http/controllers/RemoveCardFromCollectionController'; 10 11 import { GetUrlMetadataController } from '../../../../modules/cards/infrastructure/http/controllers/GetUrlMetadataController'; ··· 46 47 addCardToLibraryController: AddCardToLibraryController; 47 48 addCardToCollectionController: AddCardToCollectionController; 48 49 updateNoteCardController: UpdateNoteCardController; 50 + updateUrlCardAssociationsController: UpdateUrlCardAssociationsController; 49 51 removeCardFromLibraryController: RemoveCardFromLibraryController; 50 52 removeCardFromCollectionController: RemoveCardFromCollectionController; 51 53 getUrlMetadataController: GetUrlMetadataController; ··· 108 110 updateNoteCardController: new UpdateNoteCardController( 109 111 useCases.updateNoteCardUseCase, 110 112 ), 113 + updateUrlCardAssociationsController: 114 + new UpdateUrlCardAssociationsController( 115 + useCases.updateUrlCardAssociationsUseCase, 116 + ), 111 117 removeCardFromLibraryController: new RemoveCardFromLibraryController( 112 118 useCases.removeCardFromLibraryUseCase, 113 119 ),
+8
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 5 5 import { AddCardToLibraryUseCase } from '../../../../modules/cards/application/useCases/commands/AddCardToLibraryUseCase'; 6 6 import { AddCardToCollectionUseCase } from '../../../../modules/cards/application/useCases/commands/AddCardToCollectionUseCase'; 7 7 import { UpdateNoteCardUseCase } from '../../../../modules/cards/application/useCases/commands/UpdateNoteCardUseCase'; 8 + import { UpdateUrlCardAssociationsUseCase } from '../../../../modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 8 9 import { RemoveCardFromLibraryUseCase } from '../../../../modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase'; 9 10 import { RemoveCardFromCollectionUseCase } from '../../../../modules/cards/application/useCases/commands/RemoveCardFromCollectionUseCase'; 10 11 import { GetUrlMetadataUseCase } from '../../../../modules/cards/application/useCases/queries/GetUrlMetadataUseCase'; ··· 44 45 addCardToLibraryUseCase: AddCardToLibraryUseCase; 45 46 addCardToCollectionUseCase: AddCardToCollectionUseCase; 46 47 updateNoteCardUseCase: UpdateNoteCardUseCase; 48 + updateUrlCardAssociationsUseCase: UpdateUrlCardAssociationsUseCase; 47 49 removeCardFromLibraryUseCase: RemoveCardFromLibraryUseCase; 48 50 removeCardFromCollectionUseCase: RemoveCardFromCollectionUseCase; 49 51 getUrlMetadataUseCase: GetUrlMetadataUseCase; ··· 130 132 updateNoteCardUseCase: new UpdateNoteCardUseCase( 131 133 repositories.cardRepository, 132 134 services.cardPublisher, 135 + ), 136 + updateUrlCardAssociationsUseCase: new UpdateUrlCardAssociationsUseCase( 137 + repositories.cardRepository, 138 + services.cardLibraryService, 139 + services.cardCollectionService, 140 + services.eventPublisher, 133 141 ), 134 142 removeCardFromLibraryUseCase: new RemoveCardFromLibraryUseCase( 135 143 repositories.cardRepository,