A social knowledge tool for researchers built on ATProto

Merge pull request #59 from cosmik-network/feature/personal-url-cards

Feature/personal url cards

authored by

Wesley Finck and committed by
GitHub
d4a57572 1caa476d

+5525 -1966
+1
docs/features/GUIDE.md
··· 346 346 src/shared/infrastructure/http/factories/UseCaseFactory.ts 347 347 src/modules/cards/domain/Card.ts 348 348 src/modules/cards/domain/events/CardAddedToLibraryEvent.ts 349 + src/modules/cards/infrastructure/http/routes/index.ts 349 350 ```
-3
lexicons.json
··· 1 - { 2 - "lexicons": ["com.atproto.repo.strongRef"] 3 - }
+42 -33
src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts
··· 6 6 import dotenv from 'dotenv'; 7 7 import { AppPasswordAgentService } from './AppPasswordAgentService'; 8 8 import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 9 + import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 9 10 10 11 // Load environment variables from .env.test 11 12 dotenv.config({ path: '.env.test' }); ··· 17 18 let publisher: ATProtoCardPublisher; 18 19 let curatorId: CuratorId; 19 20 let publishedCardIds: PublishedRecordId[] = []; 21 + const envConfig: EnvironmentConfigService = new EnvironmentConfigService(); 20 22 21 23 beforeAll(async () => { 22 24 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { ··· 30 32 password: process.env.BSKY_APP_PASSWORD, 31 33 }); 32 34 33 - publisher = new ATProtoCardPublisher(agentService); 35 + publisher = new ATProtoCardPublisher( 36 + agentService, 37 + envConfig.getAtProtoConfig().collections.card, 38 + ); 34 39 curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); 35 40 }); 36 41 ··· 307 312 .withUrl(parentUrl) // Optional: include the same URL as reference 308 313 .buildOrThrow(); 309 314 310 - // Add note card to curator's library 311 315 noteCard.addToLibrary(curatorId); 312 316 313 317 // 3. Publish the note card 314 318 const notePublishResult = await publisher.publishCardToLibrary( 315 319 noteCard, 316 320 curatorId, 321 + parentUrlCard, 317 322 ); 318 323 expect(notePublishResult.isOk()).toBe(true); 319 324 ··· 383 388 }, 20000); 384 389 }); 385 390 386 - describe('Card with Original Published Record ID', () => { 387 - it('should publish a card that references an original published record', async () => { 391 + describe('Note Card with Original Published Record ID', () => { 392 + it('should publish a note card that references an original published record', async () => { 388 393 // Skip test if credentials are not available 389 394 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 390 395 console.warn('Skipping test: BSKY credentials not found in .env.test'); 391 396 return; 392 397 } 393 398 394 - const testUrl = URL.create( 395 - 'https://example.com/original-article', 399 + const referenceUrl = URL.create( 400 + 'https://example.com/referenced-article', 396 401 ).unwrap(); 397 402 398 - // Create URL metadata 399 - const metadata = UrlMetadata.create({ 400 - url: testUrl.value, 401 - title: 'Original Article', 402 - description: 'An original article that will be referenced', 403 - author: 'Original Author', 404 - siteName: 'Example.com', 405 - type: 'article', 406 - retrievedAt: new Date(), 407 - }).unwrap(); 408 - 409 - // Create a card with an original published record ID (simulating a card that references another published card) 403 + // Create a note card with an original published record ID (simulating a note that references another published card) 410 404 const originalRecordId = { 411 405 uri: 'at://did:plc:example/network.cosmik.card/original123', 412 406 cid: 'bafyoriginal123', 413 407 }; 414 408 415 - const cardWithOriginal = new CardBuilder() 416 - .withCuratorId(curatorId.value) 417 - .withUrlCard(testUrl, metadata) 418 - .withUrl(testUrl) 419 - .withOriginalPublishedRecordId(originalRecordId) 409 + const curatorId2 = CuratorId.create('did:plc:defaultCurator2').unwrap(); 410 + const noteCardWithOriginal = new CardBuilder() 411 + .withCuratorId(curatorId2.value) 412 + .withNoteCard('This is my note about the referenced article') 413 + .withUrl(referenceUrl) // Optional URL reference 414 + .withPublishedRecordId(originalRecordId) 420 415 .buildOrThrow(); 421 416 422 417 // Add card to curator's library first 423 - cardWithOriginal.addToLibrary(curatorId); 418 + noteCardWithOriginal.addToLibrary(curatorId2); 419 + noteCardWithOriginal.markCardInLibraryAsPublished( 420 + curatorId2, 421 + PublishedRecordId.create(originalRecordId), 422 + ); 423 + noteCardWithOriginal.addToLibrary(curatorId); 424 424 425 425 // 1. Publish the card 426 426 const publishResult = await publisher.publishCardToLibrary( 427 - cardWithOriginal, 427 + noteCardWithOriginal, 428 428 curatorId, 429 429 ); 430 430 expect(publishResult.isOk()).toBe(true); ··· 434 434 publishedCardIds.push(publishedRecordId); 435 435 436 436 console.log( 437 - `Published card with original record ID: ${publishedRecordId.getValue().uri}`, 437 + `Published note card with original record ID: ${publishedRecordId.getValue().uri}`, 438 438 ); 439 439 440 440 // Verify the card has the original published record ID 441 - expect(cardWithOriginal.originalPublishedRecordId).toBeDefined(); 442 - expect(cardWithOriginal.originalPublishedRecordId!.getValue().uri).toBe( 441 + expect(noteCardWithOriginal.publishedRecordId).toBeDefined(); 442 + expect(noteCardWithOriginal.publishedRecordId!.getValue().uri).toBe( 443 443 originalRecordId.uri, 444 444 ); 445 - expect(cardWithOriginal.originalPublishedRecordId!.getValue().cid).toBe( 445 + expect(noteCardWithOriginal.publishedRecordId!.getValue().cid).toBe( 446 446 originalRecordId.cid, 447 447 ); 448 448 449 + // Verify it's a note card with optional URL 450 + expect(noteCardWithOriginal.isNoteCard).toBe(true); 451 + expect(noteCardWithOriginal.url).toBe(referenceUrl); 452 + 449 453 // Mark card as published in library 450 - cardWithOriginal.markCardInLibraryAsPublished( 454 + noteCardWithOriginal.markCardInLibraryAsPublished( 451 455 curatorId, 452 456 publishedRecordId, 453 457 ); 454 - expect(cardWithOriginal.isInLibrary(curatorId)).toBe(true); 458 + expect(noteCardWithOriginal.isInLibrary(curatorId)).toBe(true); 455 459 456 460 // 2. Unpublish the card 457 461 if (UNPUBLISH) { ··· 461 465 ); 462 466 expect(unpublishResult.isOk()).toBe(true); 463 467 464 - console.log('Successfully unpublished card with original record ID'); 468 + console.log( 469 + 'Successfully unpublished note card with original record ID', 470 + ); 465 471 466 472 // Remove from cleanup list since we've already unpublished it 467 473 publishedCardIds = publishedCardIds.filter( ··· 482 488 password: 'invalid-password', 483 489 }); 484 490 485 - const invalidPublisher = new ATProtoCardPublisher(invalidAgentService); 491 + const invalidPublisher = new ATProtoCardPublisher( 492 + invalidAgentService, 493 + envConfig.getAtProtoConfig().collections.card, 494 + ); 486 495 487 496 const invalidCuratorId = CuratorId.create('did:plc:invalid').unwrap(); 488 497 const testCard = new CardBuilder()
+200 -40
src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts
··· 3 3 import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 4 4 import { CollectionBuilder } from 'src/modules/cards/tests/utils/builders/CollectionBuilder'; 5 5 import { CardBuilder } from 'src/modules/cards/tests/utils/builders/CardBuilder'; 6 - import { 7 - Collection, 8 - CollectionAccessType, 9 - } from 'src/modules/cards/domain/Collection'; 10 - import { Card } from 'src/modules/cards/domain/Card'; 6 + import { CollectionAccessType } from 'src/modules/cards/domain/Collection'; 11 7 import { URL } from 'src/modules/cards/domain/value-objects/URL'; 12 8 import { UrlMetadata } from 'src/modules/cards/domain/value-objects/UrlMetadata'; 13 9 import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; 14 10 import dotenv from 'dotenv'; 15 11 import { AppPasswordAgentService } from './AppPasswordAgentService'; 12 + import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 16 13 17 14 // Load environment variables from .env.test 18 15 dotenv.config({ path: '.env.test' }); 19 16 17 + // Set to false to skip unpublishing (useful for debugging published records) 18 + const UNPUBLISH = false; 19 + 20 20 describe('ATProtoCollectionPublisher', () => { 21 21 let collectionPublisher: ATProtoCollectionPublisher; 22 22 let cardPublisher: FakeCardPublisher; 23 23 let curatorId: CuratorId; 24 24 let publishedCollectionIds: PublishedRecordId[] = []; 25 25 let publishedLinkIds: PublishedRecordId[] = []; 26 + const envConfig: EnvironmentConfigService = new EnvironmentConfigService(); 26 27 27 28 beforeAll(async () => { 28 29 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { ··· 36 37 password: process.env.BSKY_APP_PASSWORD, 37 38 }); 38 39 39 - collectionPublisher = new ATProtoCollectionPublisher(agentService); 40 + collectionPublisher = new ATProtoCollectionPublisher( 41 + agentService, 42 + envConfig.getAtProtoConfig().collections.collection, 43 + envConfig.getAtProtoConfig().collections.collectionLink, 44 + ); 40 45 cardPublisher = new FakeCardPublisher(); 41 46 curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); 42 47 }); 43 48 44 49 afterAll(async () => { 45 - // Skip cleanup if credentials are not available 46 - if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 50 + // Skip cleanup if credentials are not available or UNPUBLISH is false 51 + if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD || !UNPUBLISH) { 52 + if (!UNPUBLISH) { 53 + console.log('Skipping cleanup: UNPUBLISH is set to false'); 54 + } 47 55 return; 48 56 } 49 57 ··· 115 123 } 116 124 117 125 // 3. Unpublish the collection 118 - const unpublishResult = 119 - await collectionPublisher.unpublish(collectionRecordId); 120 - expect(unpublishResult.isOk()).toBe(true); 126 + if (UNPUBLISH) { 127 + const unpublishResult = 128 + await collectionPublisher.unpublish(collectionRecordId); 129 + expect(unpublishResult.isOk()).toBe(true); 121 130 122 - console.log('Successfully unpublished empty collection'); 131 + console.log('Successfully unpublished empty collection'); 123 132 124 - // Remove from cleanup list since we've already unpublished it 125 - publishedCollectionIds = publishedCollectionIds.filter( 126 - (id) => id !== collectionRecordId, 127 - ); 133 + // Remove from cleanup list since we've already unpublished it 134 + publishedCollectionIds = publishedCollectionIds.filter( 135 + (id) => id !== collectionRecordId, 136 + ); 137 + } else { 138 + console.log('Skipping unpublish: UNPUBLISH is set to false'); 139 + } 128 140 } 129 141 }, 15000); 130 142 ··· 148 160 .withCuratorId(curatorId.value) 149 161 .withUrlCard(testUrl1, metadata1) 150 162 .withUrl(testUrl1) 151 - .withOriginalPublishedRecordId({ 163 + .withPublishedRecordId({ 152 164 uri: 'at://did:plc:original/network.cosmik.card/original1', 153 165 cid: 'bafyoriginal1', 154 166 }) ··· 166 178 .withCuratorId(curatorId.value) 167 179 .withUrlCard(testUrl2, metadata2) 168 180 .withUrl(testUrl2) 169 - .withOriginalPublishedRecordId({ 181 + .withPublishedRecordId({ 170 182 uri: 'at://did:plc:original/network.cosmik.card/original2', 171 183 cid: 'bafyoriginal2', 172 184 }) ··· 266 278 .withCuratorId(curatorId.value) 267 279 .withUrlCard(testUrl3, metadata3) 268 280 .withUrl(testUrl3) 269 - .withOriginalPublishedRecordId({ 281 + .withPublishedRecordId({ 270 282 uri: 'at://did:plc:original/network.cosmik.card/original3', 271 283 cid: 'bafyoriginal3', 272 284 }) ··· 301 313 ); 302 314 303 315 // 6. Unpublish the collection 304 - const unpublishResult = 305 - await collectionPublisher.unpublish(collectionRecordId); 306 - expect(unpublishResult.isOk()).toBe(true); 316 + if (UNPUBLISH) { 317 + const unpublishResult = 318 + await collectionPublisher.unpublish(collectionRecordId); 319 + expect(unpublishResult.isOk()).toBe(true); 307 320 308 - console.log('Successfully unpublished collection with cards'); 321 + console.log('Successfully unpublished collection with cards'); 309 322 310 - // Remove from cleanup list since we've already unpublished it 311 - publishedCollectionIds = publishedCollectionIds.filter( 312 - (id) => id !== collectionRecordId, 323 + // Remove from cleanup list since we've already unpublished it 324 + publishedCollectionIds = publishedCollectionIds.filter( 325 + (id) => id !== collectionRecordId, 326 + ); 327 + } else { 328 + console.log('Skipping unpublish: UNPUBLISH is set to false'); 329 + } 330 + } 331 + }, 30000); 332 + 333 + it('should publish a collection with a card shared between users', async () => { 334 + // Skip test if credentials are not available 335 + if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 336 + console.warn('Skipping test: BSKY credentials not found in .env.test'); 337 + return; 338 + } 339 + 340 + // Create two different users (we'll simulate userB with the same credentials for testing) 341 + const userA = CuratorId.create('did:plc:userA').unwrap(); 342 + const userB = curatorId; // Use our test credentials as userB 343 + 344 + // 1. User A creates and publishes a note card to their library 345 + const originalNote = new CardBuilder() 346 + .withCuratorId(userA.value) 347 + .withNoteCard('This is an original note created by User A') 348 + .withPublishedRecordId({ 349 + uri: 'at://did:plc:userA/network.cosmik.card/original-note', 350 + cid: 'bafyuserAnote123', 351 + }) 352 + .buildOrThrow(); 353 + 354 + // Simulate User A adding to their own library and publishing 355 + originalNote.addToLibrary(userA); 356 + const userALibraryRecordId = PublishedRecordId.create({ 357 + uri: 'at://did:plc:userA/network.cosmik.card/userA-library-note', 358 + cid: 'bafyuserAlib123', 359 + }); 360 + originalNote.markCardInLibraryAsPublished(userA, userALibraryRecordId); 361 + 362 + console.log( 363 + `Simulated User A publishing note: ${originalNote.publishedRecordId?.getValue().uri}`, 364 + ); 365 + 366 + // 2. User B adds the same card to their library 367 + const addToLibraryResult = originalNote.addToLibrary(userB); 368 + expect(addToLibraryResult.isOk()).toBe(true); 369 + 370 + // User B publishes the card to their library (this creates a new record in User B's repo) 371 + const userBPublishResult = await cardPublisher.publishCardToLibrary( 372 + originalNote, 373 + userB, 374 + ); 375 + expect(userBPublishResult.isOk()).toBe(true); 376 + const userBLibraryRecordId = userBPublishResult.unwrap(); 377 + originalNote.markCardInLibraryAsPublished(userB, userBLibraryRecordId); 378 + 379 + console.log( 380 + `User B published note to their library: ${userBLibraryRecordId.getValue().uri}`, 381 + ); 382 + 383 + // 3. User B creates a collection 384 + const userBCollection = new CollectionBuilder() 385 + .withAuthorId(userB.value) 386 + .withName('User B Collection with Shared Card') 387 + .withDescription( 388 + 'Collection containing a card originally created by User A', 389 + ) 390 + .withAccessType(CollectionAccessType.OPEN) 391 + .buildOrThrow(); 392 + 393 + // 4. User B adds the card to their collection 394 + const addCardResult = userBCollection.addCard(originalNote.cardId, userB); 395 + expect(addCardResult.isOk()).toBe(true); 396 + 397 + // 5. Publish User B's collection 398 + const collectionPublishResult = 399 + await collectionPublisher.publish(userBCollection); 400 + expect(collectionPublishResult.isOk()).toBe(true); 401 + 402 + if (collectionPublishResult.isOk()) { 403 + const collectionRecordId = collectionPublishResult.value; 404 + publishedCollectionIds.push(collectionRecordId); 405 + userBCollection.markAsPublished(collectionRecordId); 406 + 407 + console.log( 408 + `Published User B's collection: ${collectionRecordId.getValue().uri}`, 313 409 ); 410 + 411 + // 6. Publish the collection link (this should reference both User B's copy and User A's original) 412 + const linkPublishResult = 413 + await collectionPublisher.publishCardAddedToCollection( 414 + originalNote, 415 + userBCollection, 416 + userB, 417 + ); 418 + expect(linkPublishResult.isOk()).toBe(true); 419 + 420 + if (linkPublishResult.isOk()) { 421 + const linkRecordId = linkPublishResult.value; 422 + publishedLinkIds.push(linkRecordId); 423 + userBCollection.markCardLinkAsPublished( 424 + originalNote.cardId, 425 + linkRecordId, 426 + ); 427 + 428 + console.log( 429 + `Published collection link with cross-user card reference: ${linkRecordId.getValue().uri}`, 430 + ); 431 + 432 + // Verify the card has different library memberships 433 + expect(originalNote.libraryMembershipCount).toBe(2); 434 + expect(originalNote.isInLibrary(userA)).toBe(true); 435 + expect(originalNote.isInLibrary(userB)).toBe(true); 436 + 437 + // Verify User B's library membership has a different published record ID than the original 438 + const userBMembership = originalNote.getLibraryInfo(userB); 439 + expect(userBMembership).toBeDefined(); 440 + expect(userBMembership!.publishedRecordId).toBeDefined(); 441 + expect(userBMembership!.publishedRecordId!.uri).not.toBe( 442 + originalNote.publishedRecordId!.uri, 443 + ); 444 + 445 + console.log( 446 + `Verified cross-user card sharing: Original=${originalNote.publishedRecordId!.uri}, UserB=${userBMembership!.publishedRecordId!.uri}`, 447 + ); 448 + } 449 + 450 + // 7. Clean up 451 + if (UNPUBLISH) { 452 + const unpublishResult = 453 + await collectionPublisher.unpublish(collectionRecordId); 454 + expect(unpublishResult.isOk()).toBe(true); 455 + 456 + console.log('Successfully unpublished collection with shared card'); 457 + 458 + publishedCollectionIds = publishedCollectionIds.filter( 459 + (id) => id !== collectionRecordId, 460 + ); 461 + } else { 462 + console.log('Skipping unpublish: UNPUBLISH is set to false'); 463 + } 314 464 } 315 465 }, 30000); 316 466 }); ··· 355 505 expect(collection.collaboratorIds[0]?.value).toBe(collaboratorDid); 356 506 357 507 // 2. Unpublish the collection 358 - const unpublishResult = 359 - await collectionPublisher.unpublish(collectionRecordId); 360 - expect(unpublishResult.isOk()).toBe(true); 508 + if (UNPUBLISH) { 509 + const unpublishResult = 510 + await collectionPublisher.unpublish(collectionRecordId); 511 + expect(unpublishResult.isOk()).toBe(true); 361 512 362 - console.log('Successfully unpublished closed collection'); 513 + console.log('Successfully unpublished closed collection'); 363 514 364 - // Remove from cleanup list since we've already unpublished it 365 - publishedCollectionIds = publishedCollectionIds.filter( 366 - (id) => id !== collectionRecordId, 367 - ); 515 + // Remove from cleanup list since we've already unpublished it 516 + publishedCollectionIds = publishedCollectionIds.filter( 517 + (id) => id !== collectionRecordId, 518 + ); 519 + } else { 520 + console.log('Skipping unpublish: UNPUBLISH is set to false'); 521 + } 368 522 } 369 523 }, 15000); 370 524 }); ··· 379 533 380 534 const invalidPublisher = new ATProtoCollectionPublisher( 381 535 invalidAgentService, 536 + envConfig.getAtProtoConfig().collections.collection, 537 + envConfig.getAtProtoConfig().collections.collectionLink, 382 538 ); 383 539 384 540 const testCollection = new CollectionBuilder() ··· 468 624 } 469 625 470 626 // Clean up 471 - const unpublishResult = 472 - await collectionPublisher.unpublish(collectionRecordId); 473 - expect(unpublishResult.isOk()).toBe(true); 474 - publishedCollectionIds = publishedCollectionIds.filter( 475 - (id) => id !== collectionRecordId, 476 - ); 627 + if (UNPUBLISH) { 628 + const unpublishResult = 629 + await collectionPublisher.unpublish(collectionRecordId); 630 + expect(unpublishResult.isOk()).toBe(true); 631 + publishedCollectionIds = publishedCollectionIds.filter( 632 + (id) => id !== collectionRecordId, 633 + ); 634 + } else { 635 + console.log('Skipping unpublish: UNPUBLISH is set to false'); 636 + } 477 637 } 478 638 }, 15000); 479 639 });
+9 -334
src/modules/atproto/infrastructure/lexicon/lexicons.ts
··· 10 10 import { type $Typed, is$typed, maybe$typed } from './util.js'; 11 11 12 12 export const schemaDict = { 13 - NetworkCosmikAnnotation: { 14 - lexicon: 1, 15 - id: 'network.cosmik.annotation', 16 - description: 'A single record type for all annotations.', 17 - defs: { 18 - main: { 19 - type: 'record', 20 - description: 'A record representing an annotation on a resource.', 21 - key: 'tid', 22 - record: { 23 - type: 'object', 24 - required: ['url', 'field', 'value'], 25 - properties: { 26 - url: { 27 - type: 'string', 28 - format: 'uri', 29 - description: 30 - 'The primary URL identifying the annotated resource.', 31 - }, 32 - additionalIdentifiers: { 33 - type: 'array', 34 - items: { 35 - type: 'ref', 36 - ref: 'lex:network.cosmik.defs#identifier', 37 - }, 38 - description: 'Optional additional identifiers for the resource.', 39 - }, 40 - field: { 41 - type: 'ref', 42 - description: 43 - 'A strong reference to the specific annotation field record being used.', 44 - ref: 'lex:com.atproto.repo.strongRef', 45 - }, 46 - fromTemplates: { 47 - type: 'array', 48 - items: { 49 - type: 'ref', 50 - description: 51 - 'Optional strong reference to the template record used.', 52 - ref: 'lex:com.atproto.repo.strongRef', 53 - }, 54 - }, 55 - note: { 56 - type: 'string', 57 - description: 58 - 'An optional user-provided note about the annotation.', 59 - }, 60 - value: { 61 - type: 'union', 62 - description: 63 - 'The specific value of the annotation, determined by the field type.', 64 - refs: [ 65 - 'lex:network.cosmik.annotation#dyadValue', 66 - 'lex:network.cosmik.annotation#triadValue', 67 - 'lex:network.cosmik.annotation#ratingValue', 68 - 'lex:network.cosmik.annotation#singleSelectValue', 69 - 'lex:network.cosmik.annotation#multiSelectValue', 70 - ], 71 - }, 72 - createdAt: { 73 - type: 'string', 74 - format: 'datetime', 75 - description: 76 - 'Timestamp when this annotation was created (usually set by PDS).', 77 - }, 78 - }, 79 - }, 80 - }, 81 - dyadValue: { 82 - type: 'object', 83 - description: 'Value structure for a dyad annotation.', 84 - required: ['value'], 85 - properties: { 86 - value: { 87 - type: 'integer', 88 - description: 'Value representing the relationship between sides', 89 - minimum: 0, 90 - maximum: 100, 91 - }, 92 - }, 93 - }, 94 - triadValue: { 95 - type: 'object', 96 - description: 'Value structure for a triad annotation.', 97 - required: ['vertexA', 'vertexB', 'vertexC'], 98 - properties: { 99 - vertexA: { 100 - type: 'integer', 101 - description: 'Value for vertex A', 102 - minimum: 0, 103 - maximum: 1000, 104 - }, 105 - vertexB: { 106 - type: 'integer', 107 - description: 'Value for vertex B', 108 - minimum: 0, 109 - maximum: 1000, 110 - }, 111 - vertexC: { 112 - type: 'integer', 113 - description: 'Value for vertex C', 114 - minimum: 0, 115 - maximum: 1000, 116 - }, 117 - sum: { 118 - type: 'integer', 119 - description: 'Sum of the values for the vertices', 120 - const: 1000, 121 - }, 122 - }, 123 - }, 124 - ratingValue: { 125 - type: 'object', 126 - description: 'Value structure for a rating annotation.', 127 - required: ['rating'], 128 - properties: { 129 - rating: { 130 - type: 'integer', 131 - description: 'The star rating value', 132 - minimum: 1, 133 - maximum: 10, 134 - }, 135 - }, 136 - }, 137 - singleSelectValue: { 138 - type: 'object', 139 - description: 'Value structure for a single-select annotation.', 140 - required: ['option'], 141 - properties: { 142 - option: { 143 - type: 'string', 144 - description: 'The selected option', 145 - }, 146 - }, 147 - }, 148 - multiSelectValue: { 149 - type: 'object', 150 - description: 'Value structure for a multi-select annotation.', 151 - required: ['options'], 152 - properties: { 153 - options: { 154 - type: 'array', 155 - description: 'The selected options', 156 - items: { 157 - type: 'string', 158 - }, 159 - }, 160 - }, 161 - }, 162 - }, 163 - }, 164 - NetworkCosmikAnnotationField: { 165 - lexicon: 1, 166 - id: 'network.cosmik.annotationField', 167 - description: 'A single record type for all annotation fields.', 168 - defs: { 169 - main: { 170 - type: 'record', 171 - description: 'A record defining an annotation field.', 172 - key: 'tid', 173 - record: { 174 - type: 'object', 175 - required: ['name', 'description', 'definition'], 176 - properties: { 177 - name: { 178 - type: 'string', 179 - description: 'Name of the annotation field', 180 - }, 181 - description: { 182 - type: 'string', 183 - description: 'Description of the annotation field', 184 - }, 185 - createdAt: { 186 - type: 'string', 187 - format: 'datetime', 188 - description: 'Timestamp when this field was created', 189 - }, 190 - definition: { 191 - type: 'union', 192 - description: 193 - 'The specific definition of the field, determining its type and constraints.', 194 - refs: [ 195 - 'lex:network.cosmik.annotationField#dyadFieldDef', 196 - 'lex:network.cosmik.annotationField#triadFieldDef', 197 - 'lex:network.cosmik.annotationField#ratingFieldDef', 198 - 'lex:network.cosmik.annotationField#singleSelectFieldDef', 199 - 'lex:network.cosmik.annotationField#multiSelectFieldDef', 200 - ], 201 - }, 202 - }, 203 - }, 204 - }, 205 - dyadFieldDef: { 206 - type: 'object', 207 - description: 'Definition structure for a dyad field.', 208 - required: ['sideA', 'sideB'], 209 - properties: { 210 - sideA: { 211 - type: 'string', 212 - description: 'Label for side A of the dyad', 213 - }, 214 - sideB: { 215 - type: 'string', 216 - description: 'Label for side B of the dyad', 217 - }, 218 - }, 219 - }, 220 - triadFieldDef: { 221 - type: 'object', 222 - description: 'Definition structure for a triad field.', 223 - required: ['vertexA', 'vertexB', 'vertexC'], 224 - properties: { 225 - vertexA: { 226 - type: 'string', 227 - description: 'Label for vertex A of the triad', 228 - }, 229 - vertexB: { 230 - type: 'string', 231 - description: 'Label for vertex B of the triad', 232 - }, 233 - vertexC: { 234 - type: 'string', 235 - description: 'Label for vertex C of the triad', 236 - }, 237 - }, 238 - }, 239 - ratingFieldDef: { 240 - type: 'object', 241 - description: 'Definition structure for a rating field.', 242 - required: ['numberOfStars'], 243 - properties: { 244 - numberOfStars: { 245 - type: 'integer', 246 - description: 'Maximum number of stars for the rating', 247 - const: 5, 248 - }, 249 - }, 250 - }, 251 - singleSelectFieldDef: { 252 - type: 'object', 253 - description: 'Definition structure for a single-select field.', 254 - required: ['options'], 255 - properties: { 256 - options: { 257 - type: 'array', 258 - description: 'Available options for selection', 259 - items: { 260 - type: 'string', 261 - }, 262 - }, 263 - }, 264 - }, 265 - multiSelectFieldDef: { 266 - type: 'object', 267 - description: 'Definition structure for a multi-select field.', 268 - required: ['options'], 269 - properties: { 270 - options: { 271 - type: 'array', 272 - description: 'Available options for selection', 273 - items: { 274 - type: 'string', 275 - }, 276 - }, 277 - }, 278 - }, 279 - }, 280 - }, 281 - NetworkCosmikAnnotationTemplate: { 282 - lexicon: 1, 283 - id: 'network.cosmik.annotationTemplate', 284 - description: 'Annotation templates for grouping annotation fields', 285 - defs: { 286 - main: { 287 - type: 'record', 288 - description: 'A record of an annotation template', 289 - key: 'tid', 290 - record: { 291 - type: 'object', 292 - required: ['name', 'description', 'annotationFields'], 293 - properties: { 294 - name: { 295 - type: 'string', 296 - description: 'Name of the template', 297 - }, 298 - description: { 299 - type: 'string', 300 - description: 'Description of the template', 301 - }, 302 - annotationFields: { 303 - type: 'array', 304 - description: 305 - 'List of strong references to network.cosmik.annotationField records included in this template.', 306 - items: { 307 - type: 'ref', 308 - ref: 'lex:network.cosmik.annotationTemplate#annotationFieldRef', 309 - }, 310 - }, 311 - createdAt: { 312 - type: 'string', 313 - format: 'datetime', 314 - description: 'Timestamp when this template was created', 315 - }, 316 - }, 317 - }, 318 - }, 319 - annotationFieldRef: { 320 - type: 'object', 321 - description: 322 - 'A reference to an annotation field. Defines if the field is required in the template.', 323 - required: ['subject'], 324 - properties: { 325 - subject: { 326 - type: 'ref', 327 - ref: 'lex:com.atproto.repo.strongRef', 328 - }, 329 - required: { 330 - type: 'boolean', 331 - }, 332 - }, 333 - }, 334 - }, 335 - }, 336 13 NetworkCosmikCard: { 337 14 lexicon: 1, 338 15 id: 'network.cosmik.card', ··· 419 96 urlMetadata: { 420 97 type: 'object', 421 98 description: 'Metadata about a URL.', 422 - required: ['url'], 423 99 properties: { 424 - url: { 425 - type: 'string', 426 - format: 'uri', 427 - description: 'The URL', 428 - }, 429 100 title: { 430 101 type: 'string', 431 102 description: 'Title of the page', ··· 530 201 key: 'tid', 531 202 record: { 532 203 type: 'object', 533 - required: ['collection', 'card', 'addedBy'], 204 + required: ['collection', 'card', 'addedBy', 'addedAt'], 534 205 properties: { 535 206 collection: { 536 207 type: 'ref', ··· 539 210 }, 540 211 card: { 541 212 type: 'ref', 542 - description: 'Strong reference to the card record.', 213 + description: 214 + 'Strong reference to the card record in the users library.', 215 + ref: 'lex:com.atproto.repo.strongRef', 216 + }, 217 + originalCard: { 218 + type: 'ref', 219 + description: 220 + 'Strong reference to the original card record (may be in another library).', 543 221 ref: 'lex:com.atproto.repo.strongRef', 544 222 }, 545 223 addedBy: { ··· 641 319 } 642 320 643 321 export const ids = { 644 - NetworkCosmikAnnotation: 'network.cosmik.annotation', 645 - NetworkCosmikAnnotationField: 'network.cosmik.annotationField', 646 - NetworkCosmikAnnotationTemplate: 'network.cosmik.annotationTemplate', 647 322 NetworkCosmikCard: 'network.cosmik.card', 648 323 NetworkCosmikCollection: 'network.cosmik.collection', 649 324 NetworkCosmikCollectionLink: 'network.cosmik.collectionLink',
-140
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotation.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon'; 5 - import { CID } from 'multiformats/cid'; 6 - import { validate as _validate } from '../../../lexicons'; 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../util'; 12 - import type * as NetworkCosmikDefs from './defs.js'; 13 - import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; 14 - 15 - const is$typed = _is$typed, 16 - validate = _validate; 17 - const id = 'network.cosmik.annotation'; 18 - 19 - export interface Record { 20 - $type: 'network.cosmik.annotation'; 21 - /** The primary URL identifying the annotated resource. */ 22 - url: string; 23 - /** Optional additional identifiers for the resource. */ 24 - additionalIdentifiers?: NetworkCosmikDefs.Identifier[]; 25 - field: ComAtprotoRepoStrongRef.Main; 26 - fromTemplates?: ComAtprotoRepoStrongRef.Main[]; 27 - /** An optional user-provided note about the annotation. */ 28 - note?: string; 29 - value: 30 - | $Typed<DyadValue> 31 - | $Typed<TriadValue> 32 - | $Typed<RatingValue> 33 - | $Typed<SingleSelectValue> 34 - | $Typed<MultiSelectValue> 35 - | { $type: string }; 36 - /** Timestamp when this annotation was created (usually set by PDS). */ 37 - createdAt?: string; 38 - [k: string]: unknown; 39 - } 40 - 41 - const hashRecord = 'main'; 42 - 43 - export function isRecord<V>(v: V) { 44 - return is$typed(v, id, hashRecord); 45 - } 46 - 47 - export function validateRecord<V>(v: V) { 48 - return validate<Record & V>(v, id, hashRecord, true); 49 - } 50 - 51 - /** Value structure for a dyad annotation. */ 52 - export interface DyadValue { 53 - $type?: 'network.cosmik.annotation#dyadValue'; 54 - /** Value representing the relationship between sides */ 55 - value: number; 56 - } 57 - 58 - const hashDyadValue = 'dyadValue'; 59 - 60 - export function isDyadValue<V>(v: V) { 61 - return is$typed(v, id, hashDyadValue); 62 - } 63 - 64 - export function validateDyadValue<V>(v: V) { 65 - return validate<DyadValue & V>(v, id, hashDyadValue); 66 - } 67 - 68 - /** Value structure for a triad annotation. */ 69 - export interface TriadValue { 70 - $type?: 'network.cosmik.annotation#triadValue'; 71 - /** Value for vertex A */ 72 - vertexA: number; 73 - /** Value for vertex B */ 74 - vertexB: number; 75 - /** Value for vertex C */ 76 - vertexC: number; 77 - /** Sum of the values for the vertices */ 78 - sum?: 1000; 79 - } 80 - 81 - const hashTriadValue = 'triadValue'; 82 - 83 - export function isTriadValue<V>(v: V) { 84 - return is$typed(v, id, hashTriadValue); 85 - } 86 - 87 - export function validateTriadValue<V>(v: V) { 88 - return validate<TriadValue & V>(v, id, hashTriadValue); 89 - } 90 - 91 - /** Value structure for a rating annotation. */ 92 - export interface RatingValue { 93 - $type?: 'network.cosmik.annotation#ratingValue'; 94 - /** The star rating value */ 95 - rating: number; 96 - } 97 - 98 - const hashRatingValue = 'ratingValue'; 99 - 100 - export function isRatingValue<V>(v: V) { 101 - return is$typed(v, id, hashRatingValue); 102 - } 103 - 104 - export function validateRatingValue<V>(v: V) { 105 - return validate<RatingValue & V>(v, id, hashRatingValue); 106 - } 107 - 108 - /** Value structure for a single-select annotation. */ 109 - export interface SingleSelectValue { 110 - $type?: 'network.cosmik.annotation#singleSelectValue'; 111 - /** The selected option */ 112 - option: string; 113 - } 114 - 115 - const hashSingleSelectValue = 'singleSelectValue'; 116 - 117 - export function isSingleSelectValue<V>(v: V) { 118 - return is$typed(v, id, hashSingleSelectValue); 119 - } 120 - 121 - export function validateSingleSelectValue<V>(v: V) { 122 - return validate<SingleSelectValue & V>(v, id, hashSingleSelectValue); 123 - } 124 - 125 - /** Value structure for a multi-select annotation. */ 126 - export interface MultiSelectValue { 127 - $type?: 'network.cosmik.annotation#multiSelectValue'; 128 - /** The selected options */ 129 - options: string[]; 130 - } 131 - 132 - const hashMultiSelectValue = 'multiSelectValue'; 133 - 134 - export function isMultiSelectValue<V>(v: V) { 135 - return is$typed(v, id, hashMultiSelectValue); 136 - } 137 - 138 - export function validateMultiSelectValue<V>(v: V) { 139 - return validate<MultiSelectValue & V>(v, id, hashMultiSelectValue); 140 - }
-134
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationField.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon'; 5 - import { CID } from 'multiformats/cid'; 6 - import { validate as _validate } from '../../../lexicons'; 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../util'; 12 - 13 - const is$typed = _is$typed, 14 - validate = _validate; 15 - const id = 'network.cosmik.annotationField'; 16 - 17 - export interface Record { 18 - $type: 'network.cosmik.annotationField'; 19 - /** Name of the annotation field */ 20 - name: string; 21 - /** Description of the annotation field */ 22 - description: string; 23 - /** Timestamp when this field was created */ 24 - createdAt?: string; 25 - definition: 26 - | $Typed<DyadFieldDef> 27 - | $Typed<TriadFieldDef> 28 - | $Typed<RatingFieldDef> 29 - | $Typed<SingleSelectFieldDef> 30 - | $Typed<MultiSelectFieldDef> 31 - | { $type: string }; 32 - [k: string]: unknown; 33 - } 34 - 35 - const hashRecord = 'main'; 36 - 37 - export function isRecord<V>(v: V) { 38 - return is$typed(v, id, hashRecord); 39 - } 40 - 41 - export function validateRecord<V>(v: V) { 42 - return validate<Record & V>(v, id, hashRecord, true); 43 - } 44 - 45 - /** Definition structure for a dyad field. */ 46 - export interface DyadFieldDef { 47 - $type?: 'network.cosmik.annotationField#dyadFieldDef'; 48 - /** Label for side A of the dyad */ 49 - sideA: string; 50 - /** Label for side B of the dyad */ 51 - sideB: string; 52 - } 53 - 54 - const hashDyadFieldDef = 'dyadFieldDef'; 55 - 56 - export function isDyadFieldDef<V>(v: V) { 57 - return is$typed(v, id, hashDyadFieldDef); 58 - } 59 - 60 - export function validateDyadFieldDef<V>(v: V) { 61 - return validate<DyadFieldDef & V>(v, id, hashDyadFieldDef); 62 - } 63 - 64 - /** Definition structure for a triad field. */ 65 - export interface TriadFieldDef { 66 - $type?: 'network.cosmik.annotationField#triadFieldDef'; 67 - /** Label for vertex A of the triad */ 68 - vertexA: string; 69 - /** Label for vertex B of the triad */ 70 - vertexB: string; 71 - /** Label for vertex C of the triad */ 72 - vertexC: string; 73 - } 74 - 75 - const hashTriadFieldDef = 'triadFieldDef'; 76 - 77 - export function isTriadFieldDef<V>(v: V) { 78 - return is$typed(v, id, hashTriadFieldDef); 79 - } 80 - 81 - export function validateTriadFieldDef<V>(v: V) { 82 - return validate<TriadFieldDef & V>(v, id, hashTriadFieldDef); 83 - } 84 - 85 - /** Definition structure for a rating field. */ 86 - export interface RatingFieldDef { 87 - $type?: 'network.cosmik.annotationField#ratingFieldDef'; 88 - /** Maximum number of stars for the rating */ 89 - numberOfStars: 5; 90 - } 91 - 92 - const hashRatingFieldDef = 'ratingFieldDef'; 93 - 94 - export function isRatingFieldDef<V>(v: V) { 95 - return is$typed(v, id, hashRatingFieldDef); 96 - } 97 - 98 - export function validateRatingFieldDef<V>(v: V) { 99 - return validate<RatingFieldDef & V>(v, id, hashRatingFieldDef); 100 - } 101 - 102 - /** Definition structure for a single-select field. */ 103 - export interface SingleSelectFieldDef { 104 - $type?: 'network.cosmik.annotationField#singleSelectFieldDef'; 105 - /** Available options for selection */ 106 - options: string[]; 107 - } 108 - 109 - const hashSingleSelectFieldDef = 'singleSelectFieldDef'; 110 - 111 - export function isSingleSelectFieldDef<V>(v: V) { 112 - return is$typed(v, id, hashSingleSelectFieldDef); 113 - } 114 - 115 - export function validateSingleSelectFieldDef<V>(v: V) { 116 - return validate<SingleSelectFieldDef & V>(v, id, hashSingleSelectFieldDef); 117 - } 118 - 119 - /** Definition structure for a multi-select field. */ 120 - export interface MultiSelectFieldDef { 121 - $type?: 'network.cosmik.annotationField#multiSelectFieldDef'; 122 - /** Available options for selection */ 123 - options: string[]; 124 - } 125 - 126 - const hashMultiSelectFieldDef = 'multiSelectFieldDef'; 127 - 128 - export function isMultiSelectFieldDef<V>(v: V) { 129 - return is$typed(v, id, hashMultiSelectFieldDef); 130 - } 131 - 132 - export function validateMultiSelectFieldDef<V>(v: V) { 133 - return validate<MultiSelectFieldDef & V>(v, id, hashMultiSelectFieldDef); 134 - }
-56
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationTemplate.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon'; 5 - import { CID } from 'multiformats/cid'; 6 - import { validate as _validate } from '../../../lexicons'; 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../util'; 12 - import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; 13 - 14 - const is$typed = _is$typed, 15 - validate = _validate; 16 - const id = 'network.cosmik.annotationTemplate'; 17 - 18 - export interface Record { 19 - $type: 'network.cosmik.annotationTemplate'; 20 - /** Name of the template */ 21 - name: string; 22 - /** Description of the template */ 23 - description: string; 24 - /** List of strong references to network.cosmik.annotationField records included in this template. */ 25 - annotationFields: AnnotationFieldRef[]; 26 - /** Timestamp when this template was created */ 27 - createdAt?: string; 28 - [k: string]: unknown; 29 - } 30 - 31 - const hashRecord = 'main'; 32 - 33 - export function isRecord<V>(v: V) { 34 - return is$typed(v, id, hashRecord); 35 - } 36 - 37 - export function validateRecord<V>(v: V) { 38 - return validate<Record & V>(v, id, hashRecord, true); 39 - } 40 - 41 - /** A reference to an annotation field. Defines if the field is required in the template. */ 42 - export interface AnnotationFieldRef { 43 - $type?: 'network.cosmik.annotationTemplate#annotationFieldRef'; 44 - subject: ComAtprotoRepoStrongRef.Main; 45 - required?: boolean; 46 - } 47 - 48 - const hashAnnotationFieldRef = 'annotationFieldRef'; 49 - 50 - export function isAnnotationFieldRef<V>(v: V) { 51 - return is$typed(v, id, hashAnnotationFieldRef); 52 - } 53 - 54 - export function validateAnnotationFieldRef<V>(v: V) { 55 - return validate<AnnotationFieldRef & V>(v, id, hashAnnotationFieldRef); 56 - }
-2
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts
··· 77 77 /** Metadata about a URL. */ 78 78 export interface UrlMetadata { 79 79 $type?: 'network.cosmik.card#urlMetadata'; 80 - /** The URL */ 81 - url: string; 82 80 /** Title of the page */ 83 81 title?: string; 84 82 /** Description of the page */
+2 -1
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts
··· 19 19 $type: 'network.cosmik.collectionLink'; 20 20 collection: ComAtprotoRepoStrongRef.Main; 21 21 card: ComAtprotoRepoStrongRef.Main; 22 + originalCard?: ComAtprotoRepoStrongRef.Main; 22 23 /** DID of the user who added the card to the collection */ 23 24 addedBy: string; 24 25 /** Timestamp when the card was added to the collection. */ 25 - addedAt?: string; 26 + addedAt: string; 26 27 /** Timestamp when this link record was created (usually set by PDS). */ 27 28 createdAt?: string; 28 29 [k: string]: unknown;
-140
src/modules/atproto/infrastructure/lexicons/annotation.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "network.cosmik.annotation", 4 - "description": "A single record type for all annotations.", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A record representing an annotation on a resource.", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["url", "field", "value"], 13 - "properties": { 14 - "url": { 15 - "type": "string", 16 - "format": "uri", 17 - "description": "The primary URL identifying the annotated resource." 18 - }, 19 - "additionalIdentifiers": { 20 - "type": "array", 21 - "items": { 22 - "type": "ref", 23 - "ref": "network.cosmik.defs#identifier" 24 - }, 25 - "description": "Optional additional identifiers for the resource." 26 - }, 27 - "field": { 28 - "type": "ref", 29 - "description": "A strong reference to the specific annotation field record being used.", 30 - "ref": "com.atproto.repo.strongRef" 31 - }, 32 - "fromTemplates": { 33 - "type": "array", 34 - "items": { 35 - "type": "ref", 36 - "description": "Optional strong reference to the template record used.", 37 - "ref": "com.atproto.repo.strongRef" 38 - } 39 - }, 40 - "note": { 41 - "type": "string", 42 - "description": "An optional user-provided note about the annotation." 43 - }, 44 - "value": { 45 - "type": "union", 46 - "description": "The specific value of the annotation, determined by the field type.", 47 - "refs": [ 48 - "#dyadValue", 49 - "#triadValue", 50 - "#ratingValue", 51 - "#singleSelectValue", 52 - "#multiSelectValue" 53 - ] 54 - }, 55 - "createdAt": { 56 - "type": "string", 57 - "format": "datetime", 58 - "description": "Timestamp when this annotation was created (usually set by PDS)." 59 - } 60 - } 61 - } 62 - }, 63 - "dyadValue": { 64 - "type": "object", 65 - "description": "Value structure for a dyad annotation.", 66 - "required": ["value"], 67 - "properties": { 68 - "value": { 69 - "type": "integer", 70 - "description": "Value representing the relationship between sides", 71 - "minimum": 0, 72 - "maximum": 100 73 - } 74 - } 75 - }, 76 - "triadValue": { 77 - "type": "object", 78 - "description": "Value structure for a triad annotation.", 79 - "required": ["vertexA", "vertexB", "vertexC"], 80 - "properties": { 81 - "vertexA": { 82 - "type": "integer", 83 - "description": "Value for vertex A", 84 - "minimum": 0, 85 - "maximum": 1000 86 - }, 87 - "vertexB": { 88 - "type": "integer", 89 - "description": "Value for vertex B", 90 - "minimum": 0, 91 - "maximum": 1000 92 - }, 93 - "vertexC": { 94 - "type": "integer", 95 - "description": "Value for vertex C", 96 - "minimum": 0, 97 - "maximum": 1000 98 - }, 99 - "sum": { 100 - "type": "integer", 101 - "description": "Sum of the values for the vertices", 102 - "const": 1000 103 - } 104 - } 105 - }, 106 - "ratingValue": { 107 - "type": "object", 108 - "description": "Value structure for a rating annotation.", 109 - "required": ["rating"], 110 - "properties": { 111 - "rating": { 112 - "type": "integer", 113 - "description": "The star rating value", 114 - "minimum": 1, 115 - "maximum": 10 116 - } 117 - } 118 - }, 119 - "singleSelectValue": { 120 - "type": "object", 121 - "description": "Value structure for a single-select annotation.", 122 - "required": ["option"], 123 - "properties": { 124 - "option": { "type": "string", "description": "The selected option" } 125 - } 126 - }, 127 - "multiSelectValue": { 128 - "type": "object", 129 - "description": "Value structure for a multi-select annotation.", 130 - "required": ["options"], 131 - "properties": { 132 - "options": { 133 - "type": "array", 134 - "description": "The selected options", 135 - "items": { "type": "string" } 136 - } 137 - } 138 - } 139 - } 140 - }
-112
src/modules/atproto/infrastructure/lexicons/annotationField.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "network.cosmik.annotationField", 4 - "description": "A single record type for all annotation fields.", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A record defining an annotation field.", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["name", "description", "definition"], 13 - "properties": { 14 - "name": { 15 - "type": "string", 16 - "description": "Name of the annotation field" 17 - }, 18 - "description": { 19 - "type": "string", 20 - "description": "Description of the annotation field" 21 - }, 22 - "createdAt": { 23 - "type": "string", 24 - "format": "datetime", 25 - "description": "Timestamp when this field was created" 26 - }, 27 - "definition": { 28 - "type": "union", 29 - "description": "The specific definition of the field, determining its type and constraints.", 30 - "refs": [ 31 - "#dyadFieldDef", 32 - "#triadFieldDef", 33 - "#ratingFieldDef", 34 - "#singleSelectFieldDef", 35 - "#multiSelectFieldDef" 36 - ] 37 - } 38 - } 39 - } 40 - }, 41 - "dyadFieldDef": { 42 - "type": "object", 43 - "description": "Definition structure for a dyad field.", 44 - "required": ["sideA", "sideB"], 45 - "properties": { 46 - "sideA": { 47 - "type": "string", 48 - "description": "Label for side A of the dyad" 49 - }, 50 - "sideB": { 51 - "type": "string", 52 - "description": "Label for side B of the dyad" 53 - } 54 - } 55 - }, 56 - "triadFieldDef": { 57 - "type": "object", 58 - "description": "Definition structure for a triad field.", 59 - "required": ["vertexA", "vertexB", "vertexC"], 60 - "properties": { 61 - "vertexA": { 62 - "type": "string", 63 - "description": "Label for vertex A of the triad" 64 - }, 65 - "vertexB": { 66 - "type": "string", 67 - "description": "Label for vertex B of the triad" 68 - }, 69 - "vertexC": { 70 - "type": "string", 71 - "description": "Label for vertex C of the triad" 72 - } 73 - } 74 - }, 75 - "ratingFieldDef": { 76 - "type": "object", 77 - "description": "Definition structure for a rating field.", 78 - "required": ["numberOfStars"], 79 - "properties": { 80 - "numberOfStars": { 81 - "type": "integer", 82 - "description": "Maximum number of stars for the rating", 83 - "const": 5 84 - } 85 - } 86 - }, 87 - "singleSelectFieldDef": { 88 - "type": "object", 89 - "description": "Definition structure for a single-select field.", 90 - "required": ["options"], 91 - "properties": { 92 - "options": { 93 - "type": "array", 94 - "description": "Available options for selection", 95 - "items": { "type": "string" } 96 - } 97 - } 98 - }, 99 - "multiSelectFieldDef": { 100 - "type": "object", 101 - "description": "Definition structure for a multi-select field.", 102 - "required": ["options"], 103 - "properties": { 104 - "options": { 105 - "type": "array", 106 - "description": "Available options for selection", 107 - "items": { "type": "string" } 108 - } 109 - } 110 - } 111 - } 112 - }
-53
src/modules/atproto/infrastructure/lexicons/annotationTemplate.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "network.cosmik.annotationTemplate", 4 - "description": "Annotation templates for grouping annotation fields", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A record of an annotation template", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["name", "description", "annotationFields"], 13 - "properties": { 14 - "name": { 15 - "type": "string", 16 - "description": "Name of the template" 17 - }, 18 - "description": { 19 - "type": "string", 20 - "description": "Description of the template" 21 - }, 22 - "annotationFields": { 23 - "type": "array", 24 - "description": "List of strong references to network.cosmik.annotationField records included in this template.", 25 - "items": { 26 - "type": "ref", 27 - "ref": "#annotationFieldRef" 28 - } 29 - }, 30 - "createdAt": { 31 - "type": "string", 32 - "format": "datetime", 33 - "description": "Timestamp when this template was created" 34 - } 35 - } 36 - } 37 - }, 38 - "annotationFieldRef": { 39 - "type": "object", 40 - "description": "A reference to an annotation field. Defines if the field is required in the template.", 41 - "required": ["subject"], 42 - "properties": { 43 - "subject": { 44 - "type": "ref", 45 - "ref": "com.atproto.repo.strongRef" 46 - }, 47 - "required": { 48 - "type": "boolean" 49 - } 50 - } 51 - } 52 - } 53 - }
-6
src/modules/atproto/infrastructure/lexicons/card.json
··· 76 76 "urlMetadata": { 77 77 "type": "object", 78 78 "description": "Metadata about a URL.", 79 - "required": ["url"], 80 79 "properties": { 81 - "url": { 82 - "type": "string", 83 - "format": "uri", 84 - "description": "The URL" 85 - }, 86 80 "title": { 87 81 "type": "string", 88 82 "description": "Title of the page"
+1 -7
src/modules/atproto/infrastructure/lexicons/collectionLink.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "collection", 14 - "card", 15 - "originalCard", 16 - "addedBy", 17 - "addedAt" 18 - ], 12 + "required": ["collection", "card", "addedBy", "addedAt"], 19 13 "properties": { 20 14 "collection": { 21 15 "type": "ref",
+16 -7
src/modules/atproto/infrastructure/mappers/CardMapper.ts
··· 9 9 } from '../lexicon/types/network/cosmik/card'; 10 10 import { StrongRef } from '../../domain'; 11 11 import { UrlMetadata as UrlMetadataVO } from 'src/modules/cards/domain/value-objects/UrlMetadata'; 12 + import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; 12 13 13 14 type CardRecordDTO = Record; 14 15 15 16 export class CardMapper { 16 - static toCreateRecordDTO(card: Card): CardRecordDTO { 17 + static toCreateRecordDTO( 18 + card: Card, 19 + curatorId: CuratorId, 20 + parentCard?: Card, 21 + ): CardRecordDTO { 17 22 const record: CardRecordDTO = { 18 23 $type: 'network.cosmik.card', 19 24 type: card.type.value, ··· 26 31 record.url = card.url.value; 27 32 } 28 33 29 - // Add optional original card reference 30 - if (card.originalPublishedRecordId) { 31 - const strongRef = new StrongRef( 32 - card.originalPublishedRecordId.getValue(), 33 - ); 34 + if (card.publishedRecordId && !curatorId.equals(card.curatorId)) { 35 + const strongRef = new StrongRef(card.publishedRecordId.getValue()); 34 36 record.originalCard = { 37 + uri: strongRef.getValue().uri, 38 + cid: strongRef.getValue().cid, 39 + }; 40 + } 41 + 42 + if (card.parentCardId && parentCard && parentCard.publishedRecordId) { 43 + const strongRef = new StrongRef(parentCard.publishedRecordId.getValue()); 44 + record.parentCard = { 35 45 uri: strongRef.getValue().uri, 36 46 cid: strongRef.getValue().cid, 37 47 }; ··· 74 84 private static mapUrlMetadata(metadata: UrlMetadataVO): $Typed<UrlMetadata> { 75 85 return { 76 86 $type: 'network.cosmik.card#urlMetadata', 77 - url: metadata.url, 78 87 title: metadata.title, 79 88 description: metadata.description, 80 89 author: metadata.author,
+7 -5
src/modules/atproto/infrastructure/mappers/CollectionLinkMapper.ts
··· 9 9 link: CardLink, 10 10 collectionRecord: PublishedRecordIdProps, 11 11 cardRecord: PublishedRecordIdProps, 12 - originalCardRecord: PublishedRecordIdProps, 12 + originalCardRecord?: PublishedRecordIdProps, 13 13 ): CollectionLinkRecordDTO { 14 14 return { 15 15 $type: 'network.cosmik.collectionLink', ··· 21 21 uri: cardRecord.uri, 22 22 cid: cardRecord.cid, 23 23 }, 24 - originalCard: { 25 - uri: originalCardRecord.uri, 26 - cid: originalCardRecord.cid, 27 - }, 24 + originalCard: originalCardRecord 25 + ? { 26 + uri: originalCardRecord.uri, 27 + cid: originalCardRecord.cid, 28 + } 29 + : undefined, 28 30 addedBy: link.addedBy.value, 29 31 addedAt: link.addedAt.toISOString(), 30 32 createdAt: new Date().toISOString(),
+11 -7
src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts
··· 9 9 import { DID } from '../../domain/DID'; 10 10 import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 11 11 export class ATProtoCardPublisher implements ICardPublisher { 12 - private readonly COLLECTION = 'network.cosmik.card'; 13 - 14 - constructor(private readonly agentService: IAgentService) {} 12 + constructor( 13 + private readonly agentService: IAgentService, 14 + private readonly cardCollection: string, 15 + ) {} 15 16 16 17 /** 17 18 * Publishes a Card to the curator's library in the AT Protocol ··· 19 20 async publishCardToLibrary( 20 21 card: Card, 21 22 curatorId: CuratorId, 23 + parentCard?: Card, 22 24 ): Promise<Result<PublishedRecordId, UseCaseError>> { 23 25 try { 24 - const record = CardMapper.toCreateRecordDTO(card); 26 + let record = CardMapper.toCreateRecordDTO(card, curatorId, parentCard); 27 + record.$type = this.cardCollection as any; 28 + 25 29 const curatorDidResult = DID.create(curatorId.value); 26 30 27 31 if (curatorDidResult.isErr()) { ··· 65 69 66 70 await agent.com.atproto.repo.putRecord({ 67 71 repo: curatorDid.value, 68 - collection: this.COLLECTION, 72 + collection: this.cardCollection, 69 73 rkey: rkey, 70 74 record, 71 75 }); ··· 75 79 // Create new record 76 80 const createResult = await agent.com.atproto.repo.createRecord({ 77 81 repo: curatorDid.value, 78 - collection: this.COLLECTION, 82 + collection: this.cardCollection, 79 83 record, 80 84 }); 81 85 ··· 134 138 135 139 await agent.com.atproto.repo.deleteRecord({ 136 140 repo, 137 - collection: this.COLLECTION, 141 + collection: this.cardCollection, 138 142 rkey, 139 143 }); 140 144
+27 -13
src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts
··· 3 3 import { Card } from 'src/modules/cards/domain/Card'; 4 4 import { Result, ok, err } from 'src/shared/core/Result'; 5 5 import { UseCaseError } from 'src/shared/core/UseCaseError'; 6 - import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 6 + import { 7 + PublishedRecordId, 8 + PublishedRecordIdProps, 9 + } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 7 10 import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; 8 11 import { CollectionMapper } from '../mappers/CollectionMapper'; 9 12 import { CollectionLinkMapper } from '../mappers/CollectionLinkMapper'; ··· 12 15 import { DID } from '../../domain/DID'; 13 16 14 17 export class ATProtoCollectionPublisher implements ICollectionPublisher { 15 - private readonly COLLECTION_COLLECTION = 'network.cosmik.collection'; 16 - private readonly COLLECTION_LINK_COLLECTION = 'network.cosmik.collectionLink'; 17 - 18 - constructor(private readonly agentService: IAgentService) {} 18 + constructor( 19 + private readonly agentService: IAgentService, 20 + private readonly collectionCollection: string, 21 + private readonly collectionLinkCollection: string, 22 + ) {} 19 23 20 24 /** 21 25 * Publishes a Collection record only (not the card links) ··· 54 58 // Update existing collection record 55 59 const collectionRecordDTO = 56 60 CollectionMapper.toCreateRecordDTO(collection); 61 + collectionRecordDTO.$type = this.collectionCollection as any; 57 62 58 63 const publishedRecordId = collection.publishedRecordId.getValue(); 59 64 const strongRef = new StrongRef(publishedRecordId); ··· 62 67 63 68 await agent.com.atproto.repo.putRecord({ 64 69 repo: curatorDid.value, 65 - collection: this.COLLECTION_COLLECTION, 70 + collection: this.collectionCollection, 66 71 rkey: rkey, 67 72 record: collectionRecordDTO, 68 73 }); ··· 72 77 // Create new collection record 73 78 const collectionRecordDTO = 74 79 CollectionMapper.toCreateRecordDTO(collection); 80 + collectionRecordDTO.$type = this.collectionCollection as any; 75 81 76 82 const createResult = await agent.com.atproto.repo.createRecord({ 77 83 repo: curatorDid.value, 78 - collection: this.COLLECTION_COLLECTION, 84 + collection: this.collectionCollection, 79 85 record: collectionRecordDTO, 80 86 }); 81 87 ··· 149 155 } 150 156 151 157 // Get the original published record ID 152 - if (!card.originalPublishedRecordId) { 153 - return err(new Error('Card must have an original published record ID')); 158 + if (!card.publishedRecordId) { 159 + return err(new Error('Card must have a published record ID')); 154 160 } 155 161 156 162 // Find the card link in the collection ··· 162 168 return err(new Error('Card is not linked to this collection')); 163 169 } 164 170 171 + let originalCardRecordId: PublishedRecordIdProps | undefined; 172 + if ( 173 + libraryMembership.publishedRecordId.uri !== card.publishedRecordId.uri 174 + ) { 175 + originalCardRecordId = card.publishedRecordId.getValue(); 176 + } 177 + 165 178 const linkRecordDTO = CollectionLinkMapper.toCreateRecordDTO( 166 179 cardLink, 167 180 collection.publishedRecordId.getValue(), 168 181 libraryMembership.publishedRecordId.getValue(), 169 - card.originalPublishedRecordId.getValue(), 182 + originalCardRecordId, 170 183 ); 184 + linkRecordDTO.$type = this.collectionLinkCollection as any; 171 185 172 186 const createResult = await agent.com.atproto.repo.createRecord({ 173 187 repo: curatorDid.value, 174 - collection: this.COLLECTION_LINK_COLLECTION, 188 + collection: this.collectionLinkCollection, 175 189 record: linkRecordDTO, 176 190 }); 177 191 ··· 220 234 221 235 await agent.com.atproto.repo.deleteRecord({ 222 236 repo, 223 - collection: this.COLLECTION_LINK_COLLECTION, 237 + collection: this.collectionLinkCollection, 224 238 rkey, 225 239 }); 226 240 ··· 265 279 // Delete the collection record 266 280 await agent.com.atproto.repo.deleteRecord({ 267 281 repo, 268 - collection: this.COLLECTION_COLLECTION, 282 + collection: this.collectionCollection, 269 283 rkey, 270 284 }); 271 285
+1
src/modules/cards/application/ports/ICardPublisher.ts
··· 8 8 publishCardToLibrary( 9 9 card: Card, 10 10 curatorId: CuratorId, 11 + parentCard?: Card, 11 12 ): Promise<Result<PublishedRecordId, UseCaseError>>; 12 13 13 14 unpublishCardFromLibrary(
+1 -1
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 80 80 81 81 // Check if URL card already exists 82 82 const existingUrlCardResult = 83 - await this.cardRepository.findUrlCardByUrl(url); 83 + await this.cardRepository.findUsersUrlCardByUrl(url, curatorId); 84 84 if (existingUrlCardResult.isErr()) { 85 85 return err( 86 86 AppError.UnexpectedError.create(existingUrlCardResult.error),
+12
src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts
··· 87 87 return err(new ValidationError(removeFromLibraryResult.error.message)); 88 88 } 89 89 90 + const updatedCard = removeFromLibraryResult.value; 91 + if ( 92 + updatedCard.libraryCount == 0 && 93 + updatedCard.curatorId.equals(curatorId) 94 + ) { 95 + // If no curators have this card in their library and the curator is the owner, delete the card 96 + const deleteResult = await this.cardRepository.delete(card.cardId); 97 + if (deleteResult.isErr()) { 98 + return err(AppError.UnexpectedError.create(deleteResult.error)); 99 + } 100 + } 101 + 90 102 return ok({ 91 103 cardId: card.cardId.getStringValue(), 92 104 });
+1 -2
src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts
··· 95 95 ); 96 96 } 97 97 98 - if (!noteContent.authorId.equals(curatorId)) { 98 + if (!card.curatorId.equals(curatorId)) { 99 99 return err( 100 100 new ValidationError('Only the author can update this note card'), 101 101 ); ··· 110 110 // Create new card content with updated note 111 111 const updatedCardContentResult = CardContent.createNoteContent( 112 112 request.note, 113 - curatorId, 114 113 ); 115 114 if (updatedCardContentResult.isErr()) { 116 115 return err(new ValidationError(updatedCardContentResult.error.message));
+2 -2
src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts
··· 13 13 CardSortField, 14 14 SortOrder, 15 15 } from 'src/modules/cards/domain/ICardQueryRepository'; 16 - import { REPOSITORY_COLLECTION_IDS } from 'src/modules/atproto/infrastructure/services/RepositoryCollectionIds'; 17 16 18 17 export interface GetCollectionPageByAtUriQuery { 19 18 handle: string; ··· 33 32 private identityResolutionService: IIdentityResolutionService, 34 33 private atUriResolutionService: IAtUriResolutionService, 35 34 private getCollectionPageUseCase: GetCollectionPageUseCase, 35 + private collectionString: string, 36 36 ) {} 37 37 38 38 async execute( ··· 60 60 // Construct the AT URI using the resolved DID 61 61 const atUriResult = ATUri.fromParts( 62 62 didResult.value, 63 - REPOSITORY_COLLECTION_IDS.COLLECTIONS, 63 + this.collectionString, 64 64 query.recordKey, 65 65 ); 66 66 if (atUriResult.isErr()) {
-16
src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts
··· 18 18 imageUrl?: string; 19 19 type?: string; 20 20 }; 21 - existingCardId?: string; 22 21 } 23 22 24 23 export class ValidationError extends Error { ··· 49 48 const url = urlResult.value; 50 49 51 50 try { 52 - // Check if a card already exists for this URL 53 - const existingCardResult = 54 - await this.cardRepository.findUrlCardByUrl(url); 55 - if (existingCardResult.isErr()) { 56 - return err( 57 - new Error( 58 - `Failed to check for existing card: ${existingCardResult.error instanceof Error ? existingCardResult.error.message : 'Unknown error'}`, 59 - ), 60 - ); 61 - } 62 - 63 - const existingCard = existingCardResult.value; 64 - const existingCardId = existingCard?.id?.toString(); 65 - 66 51 // Fetch metadata from external service 67 52 const metadataResult = await this.metadataService.fetchMetadata(url); 68 53 if (metadataResult.isErr()) { ··· 85 70 imageUrl: metadata.imageUrl, 86 71 type: metadata.type, 87 72 }, 88 - existingCardId, 89 73 }); 90 74 } catch (error) { 91 75 return err(
+115
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.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 { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; 8 + import { CuratorId } from '../../../domain/value-objects/CuratorId'; 9 + import { URL } from '../../../domain/value-objects/URL'; 10 + 11 + export interface GetUrlStatusForMyLibraryQuery { 12 + url: string; 13 + curatorId: string; 14 + } 15 + 16 + export interface CollectionInfo { 17 + id: string; 18 + uri?: string; 19 + name: string; 20 + description?: string; 21 + } 22 + 23 + export interface GetUrlStatusForMyLibraryResult { 24 + cardId?: string; 25 + collections?: CollectionInfo[]; 26 + } 27 + 28 + export class ValidationError extends UseCaseError { 29 + constructor(message: string) { 30 + super(message); 31 + } 32 + } 33 + 34 + export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< 35 + GetUrlStatusForMyLibraryQuery, 36 + Result< 37 + GetUrlStatusForMyLibraryResult, 38 + ValidationError | AppError.UnexpectedError 39 + > 40 + > { 41 + constructor( 42 + private cardRepository: ICardRepository, 43 + private collectionQueryRepository: ICollectionQueryRepository, 44 + eventPublisher: IEventPublisher, 45 + ) { 46 + super(eventPublisher); 47 + } 48 + 49 + async execute( 50 + query: GetUrlStatusForMyLibraryQuery, 51 + ): Promise< 52 + Result< 53 + GetUrlStatusForMyLibraryResult, 54 + ValidationError | AppError.UnexpectedError 55 + > 56 + > { 57 + try { 58 + // Validate and create CuratorId 59 + const curatorIdResult = CuratorId.create(query.curatorId); 60 + if (curatorIdResult.isErr()) { 61 + return err( 62 + new ValidationError( 63 + `Invalid curator ID: ${curatorIdResult.error.message}`, 64 + ), 65 + ); 66 + } 67 + const curatorId = curatorIdResult.value; 68 + 69 + // Validate URL 70 + const urlResult = URL.create(query.url); 71 + if (urlResult.isErr()) { 72 + return err( 73 + new ValidationError(`Invalid URL: ${urlResult.error.message}`), 74 + ); 75 + } 76 + const url = urlResult.value; 77 + 78 + // Check if user has a URL card with this URL 79 + const existingCardResult = 80 + await this.cardRepository.findUsersUrlCardByUrl(url, curatorId); 81 + if (existingCardResult.isErr()) { 82 + return err(AppError.UnexpectedError.create(existingCardResult.error)); 83 + } 84 + 85 + const card = existingCardResult.value; 86 + const result: GetUrlStatusForMyLibraryResult = {}; 87 + 88 + if (card) { 89 + result.cardId = card.cardId.getStringValue(); 90 + 91 + // Get collections containing this card for the user 92 + try { 93 + const collections = 94 + await this.collectionQueryRepository.getCollectionsContainingCardForUser( 95 + card.cardId.getStringValue(), 96 + curatorId.value, 97 + ); 98 + 99 + result.collections = collections.map((collection) => ({ 100 + id: collection.id, 101 + uri: collection.uri, 102 + name: collection.name, 103 + description: collection.description, 104 + })); 105 + } catch (error) { 106 + return err(AppError.UnexpectedError.create(error)); 107 + } 108 + } 109 + 110 + return ok(result); 111 + } catch (error) { 112 + return err(AppError.UnexpectedError.create(error)); 113 + } 114 + } 115 + }
+89 -16
src/modules/cards/domain/Card.ts
··· 9 9 import { URL } from './value-objects/URL'; 10 10 import { CardAddedToLibraryEvent } from './events/CardAddedToLibraryEvent'; 11 11 12 + export const CARD_ERROR_MESSAGES = { 13 + CARD_TYPE_CONTENT_MISMATCH: 'Card type must match content type', 14 + URL_CARD_CANNOT_HAVE_PARENT: 'URL cards cannot have parent cards', 15 + URL_CARD_MUST_HAVE_URL: 'URL cards must have a url property', 16 + URL_CARD_SINGLE_LIBRARY_ONLY: 'URL cards can only be in one library', 17 + URL_CARD_CREATOR_LIBRARY_ONLY: 18 + 'URL cards can only be in the library of the creator', 19 + LIBRARY_COUNT_MISMATCH: 20 + 'Library count does not match library memberships length', 21 + CANNOT_CHANGE_CONTENT_TYPE: 'Cannot change card content to different type', 22 + ALREADY_IN_LIBRARY: "Card is already in user's library", 23 + NOT_IN_LIBRARY: "Card is not in user's library", 24 + } as const; 25 + 12 26 export class CardValidationError extends Error { 13 27 constructor(message: string) { 14 28 super(message); ··· 23 37 } 24 38 25 39 interface CardProps { 40 + curatorId: CuratorId; 26 41 type: CardType; 27 42 content: CardContent; 28 43 url?: URL; 29 44 parentCardId?: CardId; // For NOTE and HIGHLIGHT cards that reference other cards 30 45 libraryMemberships: CardInLibraryLink[]; // Set of users who have this card in their library 31 46 libraryCount: number; // Cached count of library memberships 32 - originalPublishedRecordId?: PublishedRecordId; // The first published record ID for this card 47 + publishedRecordId?: PublishedRecordId; // The first published record ID for this card 33 48 createdAt: Date; 34 49 updatedAt: Date; 35 50 } ··· 37 52 export class Card extends AggregateRoot<CardProps> { 38 53 get cardId(): CardId { 39 54 return CardId.create(this._id).unwrap(); 55 + } 56 + 57 + get curatorId(): CuratorId { 58 + return this.props.curatorId; 40 59 } 41 60 42 61 get type(): CardType { ··· 75 94 return this.props.libraryCount; 76 95 } 77 96 78 - get originalPublishedRecordId(): PublishedRecordId | undefined { 79 - return this.props.originalPublishedRecordId; 97 + get publishedRecordId(): PublishedRecordId | undefined { 98 + return this.props.publishedRecordId; 80 99 } 81 100 82 101 // Type-specific convenience getters ··· 106 125 ): Result<Card, CardValidationError> { 107 126 // Validate content type matches card type 108 127 if (props.type.value !== props.content.type) { 109 - return err(new CardValidationError('Card type must match content type')); 128 + return err( 129 + new CardValidationError(CARD_ERROR_MESSAGES.CARD_TYPE_CONTENT_MISMATCH), 130 + ); 110 131 } 111 132 112 133 // Validate parent/source card relationships ··· 125 146 updatedAt: props.updatedAt || now, 126 147 }; 127 148 128 - return ok(new Card(cardProps, id)); 149 + const card = new Card(cardProps, id); 150 + return ok(card); 129 151 } 130 152 131 153 private static validateCardRelationships( ··· 139 161 ): Result<void, CardValidationError> { 140 162 // URL cards should not have parent cards 141 163 if (props.type.value === CardTypeEnum.URL && props.parentCardId) { 142 - return err(new CardValidationError('URL cards cannot have parent cards')); 164 + return err( 165 + new CardValidationError( 166 + CARD_ERROR_MESSAGES.URL_CARD_CANNOT_HAVE_PARENT, 167 + ), 168 + ); 143 169 } 144 170 145 171 // URL cards must have a URL property 146 172 if (props.type.value === CardTypeEnum.URL && !props.url) { 147 - return err(new CardValidationError('URL cards must have a url property')); 173 + return err( 174 + new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_MUST_HAVE_URL), 175 + ); 176 + } 177 + 178 + // URL cards can only be in one library 179 + const libraryMemberships = props.libraryMemberships || []; 180 + if ( 181 + props.type.value === CardTypeEnum.URL && 182 + libraryMemberships.length > 1 183 + ) { 184 + return err( 185 + new CardValidationError( 186 + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 187 + ), 188 + ); 189 + } 190 + 191 + // URL cards can only have library memberships for the creator 192 + if ( 193 + props.type.value === CardTypeEnum.URL && 194 + libraryMemberships.length > 0 195 + ) { 196 + const hasNonCreatorMembership = libraryMemberships.some( 197 + (membership) => !membership.curatorId.equals(props.curatorId), 198 + ); 199 + if (hasNonCreatorMembership) { 200 + return err( 201 + new CardValidationError( 202 + CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY, 203 + ), 204 + ); 205 + } 148 206 } 149 207 150 208 // Validate libraryCount matches libraryMemberships length when both are provided 151 - const libraryMemberships = props.libraryMemberships || []; 152 209 if ( 153 210 props.libraryCount !== undefined && 154 211 props.libraryCount !== libraryMemberships.length 155 212 ) { 156 213 return err( 157 214 new CardValidationError( 158 - `Library count (${props.libraryCount}) does not match library memberships length (${libraryMemberships.length})`, 215 + `${CARD_ERROR_MESSAGES.LIBRARY_COUNT_MISMATCH} (${props.libraryCount} vs ${libraryMemberships.length})`, 159 216 ), 160 217 ); 161 218 } ··· 168 225 ): Result<void, CardValidationError> { 169 226 if (this.props.type.value !== newContent.type) { 170 227 return err( 171 - new CardValidationError('Cannot change card content to different type'), 228 + new CardValidationError(CARD_ERROR_MESSAGES.CANNOT_CHANGE_CONTENT_TYPE), 172 229 ); 173 230 } 174 231 ··· 184 241 link.curatorId.equals(userId), 185 242 ) 186 243 ) { 187 - return err(new CardValidationError("Card is already in user's library")); 244 + return err( 245 + new CardValidationError(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY), 246 + ); 247 + } 248 + 249 + // URL cards can only be in one library 250 + if (this.isUrlCard && this.props.libraryMemberships.length > 0) { 251 + return err( 252 + new CardValidationError( 253 + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 254 + ), 255 + ); 188 256 } 189 257 190 258 this.props.libraryMemberships.push({ ··· 212 280 link.curatorId.equals(userId), 213 281 ) 214 282 ) { 215 - return err(new CardValidationError("Card is not in user's library")); 283 + return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); 216 284 } 217 285 218 286 this.props.libraryMemberships = this.props.libraryMemberships.filter( ··· 232 300 ); 233 301 } 234 302 303 + public markAsPublished(publishedRecordId: PublishedRecordId): void { 304 + this.props.publishedRecordId = publishedRecordId; 305 + this.props.updatedAt = new Date(); 306 + this.markCardInLibraryAsPublished(this.props.curatorId, publishedRecordId); 307 + } 308 + 235 309 public markCardInLibraryAsPublished( 236 310 userId: CuratorId, 237 311 publishedRecordId: PublishedRecordId, ··· 240 314 link.curatorId.equals(userId), 241 315 ); 242 316 if (!membership) { 243 - return err(new CardValidationError("Card is not in user's library")); 317 + return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); 244 318 } 245 319 246 320 membership.publishedRecordId = publishedRecordId; 247 321 248 - // Set the original published record ID if it hasn't been set yet 249 - if (!this.props.originalPublishedRecordId) { 250 - this.props.originalPublishedRecordId = publishedRecordId; 322 + if (!this.props.publishedRecordId) { 323 + this.props.publishedRecordId = publishedRecordId; 251 324 } 252 325 253 326 this.props.updatedAt = new Date();
+3 -3
src/modules/cards/domain/CardFactory.ts
··· 100 100 101 101 // Create the card 102 102 return Card.create({ 103 + curatorId, 103 104 type: cardType, 104 105 content, 105 106 url, ··· 123 124 return this.createUrlContent(cardInput); 124 125 125 126 case CardTypeEnum.NOTE: 126 - return this.createNoteContent(cardInput, curatorId); 127 + return this.createNoteContent(cardInput); 127 128 128 129 default: 129 130 return err(new CardValidationError('Invalid card type')); ··· 157 158 158 159 private static createNoteContent( 159 160 input: INoteCardInput, 160 - curatorId: CuratorId, 161 161 ): Result<CardContent, CardValidationError> { 162 - return CardContent.createNoteContent(input.text, curatorId); 162 + return CardContent.createNoteContent(input.text); 163 163 } 164 164 165 165 // Type guards
+5 -1
src/modules/cards/domain/ICardRepository.ts
··· 1 1 import { Result } from '../../../shared/core/Result'; 2 2 import { Card } from './Card'; 3 3 import { CardId } from './value-objects/CardId'; 4 + import { CuratorId } from './value-objects/CuratorId'; 4 5 import { URL } from './value-objects/URL'; 5 6 6 7 export interface ICardRepository { 7 8 findById(id: CardId): Promise<Result<Card | null>>; 8 9 save(card: Card): Promise<Result<void>>; 9 10 delete(cardId: CardId): Promise<Result<void>>; 10 - findUrlCardByUrl(url: URL): Promise<Result<Card | null>>; 11 + findUsersUrlCardByUrl( 12 + url: URL, 13 + curatorId: CuratorId, 14 + ): Promise<Result<Card | null>>; 11 15 }
+13
src/modules/cards/domain/ICollectionQueryRepository.ts
··· 36 36 authorId: string; // Just the curator ID, not enriched data 37 37 } 38 38 39 + // View data for collections containing a specific card 40 + export interface CollectionContainingCardDTO { 41 + id: string; 42 + uri?: string; 43 + name: string; 44 + description?: string; 45 + } 46 + 39 47 export interface ICollectionQueryRepository { 40 48 findByCreator( 41 49 curatorId: string, 42 50 options: CollectionQueryOptions, 43 51 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>>; 52 + 53 + getCollectionsContainingCardForUser( 54 + cardId: string, 55 + curatorId: string, 56 + ): Promise<CollectionContainingCardDTO[]>; 44 57 }
+41 -2
src/modules/cards/domain/services/CardLibraryService.ts
··· 37 37 // Card is already in library, nothing to do 38 38 return ok(card); 39 39 } 40 + const addToLibResult = card.addToLibrary(curatorId); 41 + if (addToLibResult.isErr()) { 42 + return err( 43 + new CardLibraryValidationError( 44 + `Failed to add card to library: ${addToLibResult.error.message}`, 45 + ), 46 + ); 47 + } 48 + let parentCard: Card | undefined = undefined; 49 + 50 + if (card.parentCardId) { 51 + // Ensure parent card is in the curator's library 52 + const parentCardResult = await this.cardRepository.findById( 53 + card.parentCardId, 54 + ); 55 + if (parentCardResult.isErr()) { 56 + return err( 57 + new CardLibraryValidationError( 58 + `Failed to fetch parent card: ${parentCardResult.error.message}`, 59 + ), 60 + ); 61 + } 62 + const parentCardValue = parentCardResult.value; 63 + 64 + if (!parentCardValue) { 65 + return err(new CardLibraryValidationError(`Parent card not found`)); 66 + } 67 + parentCard = parentCardValue; 68 + } 40 69 41 70 // Publish card to library 42 71 const publishResult = await this.cardPublisher.publishCardToLibrary( 43 72 card, 44 73 curatorId, 74 + parentCard, 45 75 ); 46 76 if (publishResult.isErr()) { 47 77 return err( ··· 52 82 } 53 83 54 84 // Mark card as published in library 55 - card.addToLibrary(curatorId); 56 - card.markCardInLibraryAsPublished(curatorId, publishResult.value); 85 + const markCardAsPublishedResult = card.markCardInLibraryAsPublished( 86 + curatorId, 87 + publishResult.value, 88 + ); 89 + if (markCardAsPublishedResult.isErr()) { 90 + return err( 91 + new CardLibraryValidationError( 92 + `Failed to mark card as published in library: ${markCardAsPublishedResult.error.message}`, 93 + ), 94 + ); 95 + } 57 96 58 97 // Save updated card 59 98 const saveResult = await this.cardRepository.save(card);
+1 -3
src/modules/cards/domain/value-objects/CardContent.ts
··· 59 59 60 60 public static createNoteContent( 61 61 text: string, 62 - curatorId: CuratorId, 63 62 ): Result<CardContent, CardContentValidationError> { 64 63 // Use provided curatorId or create a dummy one for backward compatibility 65 - const authorId = curatorId; 66 - const noteContentResult = NoteCardContent.create(authorId, text); 64 + const noteContentResult = NoteCardContent.create(text); 67 65 if (noteContentResult.isErr()) { 68 66 return err( 69 67 new CardContentValidationError(noteContentResult.error.message),
-7
src/modules/cards/domain/value-objects/content/NoteCardContent.ts
··· 13 13 interface NoteCardContentProps { 14 14 type: CardTypeEnum.NOTE; 15 15 text: string; 16 - authorId: CuratorId; 17 16 } 18 17 19 18 export class NoteCardContent extends ValueObject<NoteCardContentProps> { ··· 27 26 return this.props.text; 28 27 } 29 28 30 - get authorId(): CuratorId { 31 - return this.props.authorId; 32 - } 33 - 34 29 private constructor(props: NoteCardContentProps) { 35 30 super(props); 36 31 } 37 32 38 33 public static create( 39 - authorId: CuratorId, 40 34 text: string, 41 35 ): Result<NoteCardContent, NoteCardContentValidationError> { 42 36 if (!text || text.trim().length === 0) { ··· 57 51 new NoteCardContent({ 58 52 type: CardTypeEnum.NOTE, 59 53 text: text.trim(), 60 - authorId, 61 54 }), 62 55 ); 63 56 }
+40
src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { GetUrlStatusForMyLibraryUseCase } from '../../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class GetUrlStatusForMyLibraryController extends Controller { 7 + constructor( 8 + private getUrlStatusForMyLibraryUseCase: GetUrlStatusForMyLibraryUseCase, 9 + ) { 10 + super(); 11 + } 12 + 13 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 14 + try { 15 + const { url } = req.query; 16 + const curatorId = req.did; 17 + 18 + if (!curatorId) { 19 + return this.unauthorized(res); 20 + } 21 + 22 + if (!url || typeof url !== 'string') { 23 + return this.badRequest(res, 'URL query parameter is required'); 24 + } 25 + 26 + const result = await this.getUrlStatusForMyLibraryUseCase.execute({ 27 + url, 28 + curatorId, 29 + }); 30 + 31 + if (result.isErr()) { 32 + return this.fail(res, result.error); 33 + } 34 + 35 + return this.ok(res, result.value); 36 + } catch (error: any) { 37 + return this.fail(res, error); 38 + } 39 + } 40 + }
+9
src/modules/cards/infrastructure/http/routes/cardRoutes.ts
··· 10 10 import { GetLibrariesForCardController } from '../controllers/GetLibrariesForCardController'; 11 11 import { GetMyUrlCardsController } from '../controllers/GetMyUrlCardsController'; 12 12 import { GetUserUrlCardsController } from '../controllers/GetUserUrlCardsController'; 13 + import { GetUrlStatusForMyLibraryController } from '../controllers/GetUrlStatusForMyLibraryController'; 13 14 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 14 15 15 16 export function createCardRoutes( ··· 25 26 getLibrariesForCardController: GetLibrariesForCardController, 26 27 getMyUrlCardsController: GetMyUrlCardsController, 27 28 getUserUrlCardsController: GetUserUrlCardsController, 29 + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, 28 30 ): Router { 29 31 const router = Router(); 30 32 ··· 37 39 // GET /api/cards/my - Get my URL cards 38 40 router.get('/my', authMiddleware.ensureAuthenticated(), (req, res) => 39 41 getMyUrlCardsController.execute(req, res), 42 + ); 43 + 44 + // GET /api/cards/library/status - Get URL status for my library 45 + router.get( 46 + '/library/status', 47 + authMiddleware.ensureAuthenticated(), 48 + (req, res) => getUrlStatusForMyLibraryController.execute(req, res), 40 49 ); 41 50 42 51 // GET /api/cards/user/:identifier - Get user's URL cards by identifier
+3
src/modules/cards/infrastructure/http/routes/index.ts
··· 12 12 import { GetLibrariesForCardController } from '../controllers/GetLibrariesForCardController'; 13 13 import { GetMyUrlCardsController } from '../controllers/GetMyUrlCardsController'; 14 14 import { GetUserUrlCardsController } from '../controllers/GetUserUrlCardsController'; 15 + import { GetUrlStatusForMyLibraryController } from '../controllers/GetUrlStatusForMyLibraryController'; 15 16 import { CreateCollectionController } from '../controllers/CreateCollectionController'; 16 17 import { UpdateCollectionController } from '../controllers/UpdateCollectionController'; 17 18 import { DeleteCollectionController } from '../controllers/DeleteCollectionController'; ··· 35 36 getLibrariesForCardController: GetLibrariesForCardController, 36 37 getMyUrlCardsController: GetMyUrlCardsController, 37 38 getUserUrlCardsController: GetUserUrlCardsController, 39 + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, 38 40 // Collection controllers 39 41 createCollectionController: CreateCollectionController, 40 42 updateCollectionController: UpdateCollectionController, ··· 62 64 getLibrariesForCardController, 63 65 getMyUrlCardsController, 64 66 getUserUrlCardsController, 67 + getUrlStatusForMyLibraryController, 65 68 ), 66 69 ); 67 70
+51 -38
src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts
··· 9 9 import { CardDTO, CardMapper } from './mappers/CardMapper'; 10 10 import { Result, ok, err } from '../../../../shared/core/Result'; 11 11 import { URL } from '../../domain/value-objects/URL'; 12 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 12 13 13 14 export class DrizzleCardRepository implements ICardRepository { 14 15 constructor(private db: PostgresJsDatabase) {} ··· 47 48 ) 48 49 .where(eq(libraryMemberships.cardId, cardId)); 49 50 50 - // Get original published record if it exists 51 - let originalPublishedRecord = null; 52 - if (result.originalPublishedRecordId) { 53 - const originalRecordResult = await this.db 51 + // Get published record if it exists 52 + let publishedRecord = null; 53 + if (result.publishedRecordId) { 54 + const publishedRecordResult = await this.db 54 55 .select({ 55 56 uri: publishedRecords.uri, 56 57 cid: publishedRecords.cid, 57 58 }) 58 59 .from(publishedRecords) 59 - .where(eq(publishedRecords.id, result.originalPublishedRecordId)) 60 + .where(eq(publishedRecords.id, result.publishedRecordId)) 60 61 .limit(1); 61 62 62 - if (originalRecordResult.length > 0) { 63 - originalPublishedRecord = originalRecordResult[0]; 63 + if (publishedRecordResult.length > 0) { 64 + publishedRecord = publishedRecordResult[0]; 64 65 } 65 66 } 66 67 67 68 const cardDTO: CardDTO = { 68 69 id: result.id, 70 + curatorId: result.authorId, 69 71 type: result.type, 70 72 contentData: result.contentData, 71 73 url: result.url || undefined, 72 74 parentCardId: result.parentCardId || undefined, 73 - originalPublishedRecordId: originalPublishedRecord 75 + publishedRecordId: publishedRecord 74 76 ? { 75 - uri: originalPublishedRecord.uri, 76 - cid: originalPublishedRecord.cid, 77 + uri: publishedRecord.uri, 78 + cid: publishedRecord.cid, 77 79 } 78 80 : undefined, 79 81 libraryCount: result.libraryCount ?? 0, ··· 108 110 const { 109 111 card: cardData, 110 112 libraryMemberships: membershipData, 111 - originalPublishedRecord, 113 + publishedRecord, 112 114 membershipPublishedRecords, 113 115 } = CardMapper.toPersistence(card); 114 116 115 117 await this.db.transaction(async (tx) => { 116 - // Handle original published record if it exists 117 - let originalPublishedRecordId: string | undefined = undefined; 118 + // Handle published record if it exists 119 + let publishedRecordId: string | undefined = undefined; 118 120 119 - if (originalPublishedRecord) { 120 - const originalRecordResult = await tx 121 + if (publishedRecord) { 122 + const publishedRecordResult = await tx 121 123 .insert(publishedRecords) 122 124 .values({ 123 - id: originalPublishedRecord.id, 124 - uri: originalPublishedRecord.uri, 125 - cid: originalPublishedRecord.cid, 126 - recordedAt: originalPublishedRecord.recordedAt || new Date(), 125 + id: publishedRecord.id, 126 + uri: publishedRecord.uri, 127 + cid: publishedRecord.cid, 128 + recordedAt: publishedRecord.recordedAt || new Date(), 127 129 }) 128 130 .onConflictDoNothing({ 129 131 target: [publishedRecords.uri, publishedRecords.cid], 130 132 }) 131 133 .returning({ id: publishedRecords.id }); 132 134 133 - if (originalRecordResult.length === 0) { 135 + if (publishedRecordResult.length === 0) { 134 136 const existingRecord = await tx 135 137 .select() 136 138 .from(publishedRecords) 137 139 .where( 138 140 and( 139 - eq(publishedRecords.uri, originalPublishedRecord.uri), 140 - eq(publishedRecords.cid, originalPublishedRecord.cid), 141 + eq(publishedRecords.uri, publishedRecord.uri), 142 + eq(publishedRecords.cid, publishedRecord.cid), 141 143 ), 142 144 ) 143 145 .limit(1); 144 146 145 147 if (existingRecord.length > 0) { 146 - originalPublishedRecordId = existingRecord[0]!.id; 148 + publishedRecordId = existingRecord[0]!.id; 147 149 } 148 150 } else { 149 - originalPublishedRecordId = originalRecordResult[0]!.id; 151 + publishedRecordId = publishedRecordResult[0]!.id; 150 152 } 151 153 } 152 154 ··· 201 203 .insert(cards) 202 204 .values({ 203 205 ...cardData, 204 - originalPublishedRecordId: originalPublishedRecordId, 206 + publishedRecordId: publishedRecordId, 205 207 }) 206 208 .onConflictDoUpdate({ 207 209 target: cards.id, 208 210 set: { 211 + authorId: cardData.authorId, 209 212 type: cardData.type, 210 213 contentData: cardData.contentData, 211 214 url: cardData.url, 212 215 parentCardId: cardData.parentCardId, 213 - originalPublishedRecordId: originalPublishedRecordId, 216 + publishedRecordId: publishedRecordId, 214 217 libraryCount: cardData.libraryCount, 215 218 updatedAt: cardData.updatedAt, 216 219 }, ··· 257 260 } 258 261 } 259 262 260 - async findUrlCardByUrl(url: URL): Promise<Result<Card | null>> { 263 + async findUsersUrlCardByUrl( 264 + url: URL, 265 + curatorId: CuratorId, 266 + ): Promise<Result<Card | null>> { 261 267 try { 262 268 const urlValue = url.value; 263 269 264 270 const cardResult = await this.db 265 271 .select() 266 272 .from(cards) 267 - .where(and(eq(cards.url, urlValue), eq(cards.type, 'URL'))) 273 + .where( 274 + and( 275 + eq(cards.url, urlValue), 276 + eq(cards.type, 'URL'), 277 + eq(cards.authorId, curatorId.value), 278 + ), 279 + ) 268 280 .limit(1); 269 281 270 282 if (cardResult.length === 0) { ··· 291 303 ) 292 304 .where(eq(libraryMemberships.cardId, result.id)); 293 305 294 - // Get original published record if it exists 295 - let originalPublishedRecord = null; 296 - if (result.originalPublishedRecordId) { 297 - const originalRecordResult = await this.db 306 + // Get published record if it exists 307 + let publishedRecord = null; 308 + if (result.publishedRecordId) { 309 + const publishedRecordResult = await this.db 298 310 .select({ 299 311 uri: publishedRecords.uri, 300 312 cid: publishedRecords.cid, 301 313 }) 302 314 .from(publishedRecords) 303 - .where(eq(publishedRecords.id, result.originalPublishedRecordId)) 315 + .where(eq(publishedRecords.id, result.publishedRecordId)) 304 316 .limit(1); 305 317 306 - if (originalRecordResult.length > 0) { 307 - originalPublishedRecord = originalRecordResult[0]; 318 + if (publishedRecordResult.length > 0) { 319 + publishedRecord = publishedRecordResult[0]; 308 320 } 309 321 } 310 322 311 323 const cardDTO: CardDTO = { 312 324 id: result.id, 325 + curatorId: result.authorId, 313 326 type: result.type, 314 327 contentData: result.contentData, 315 328 url: result.url || undefined, 316 329 parentCardId: result.parentCardId || undefined, 317 - originalPublishedRecordId: originalPublishedRecord 330 + publishedRecordId: publishedRecord 318 331 ? { 319 - uri: originalPublishedRecord.uri, 320 - cid: originalPublishedRecord.cid, 332 + uri: publishedRecord.uri, 333 + cid: publishedRecord.cid, 321 334 } 322 335 : undefined, 323 336 libraryCount: result.libraryCount ?? 0,
+45 -2
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 1 - import { eq, desc, asc, count, sql, or, ilike } from 'drizzle-orm'; 1 + import { eq, desc, asc, count, sql, or, ilike, and } from 'drizzle-orm'; 2 2 import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 3 import { 4 4 ICollectionQueryRepository, ··· 7 7 CollectionQueryResultDTO, 8 8 CollectionSortField, 9 9 SortOrder, 10 + CollectionContainingCardDTO, 10 11 } from '../../domain/ICollectionQueryRepository'; 11 - import { collections } from './schema/collection.sql'; 12 + import { collections, collectionCards } from './schema/collection.sql'; 12 13 import { publishedRecords } from './schema/publishedRecord.sql'; 13 14 import { CollectionMapper } from './mappers/CollectionMapper'; 14 15 ··· 104 105 }; 105 106 } catch (error) { 106 107 console.error('Error in findByCreator:', error); 108 + throw error; 109 + } 110 + } 111 + 112 + async getCollectionsContainingCardForUser( 113 + cardId: string, 114 + curatorId: string, 115 + ): Promise<CollectionContainingCardDTO[]> { 116 + try { 117 + // Find collections authored by this curator that contain this card 118 + const collectionResults = await this.db 119 + .select({ 120 + id: collections.id, 121 + name: collections.name, 122 + description: collections.description, 123 + uri: publishedRecords.uri, 124 + }) 125 + .from(collections) 126 + .leftJoin( 127 + publishedRecords, 128 + eq(collections.publishedRecordId, publishedRecords.id), 129 + ) 130 + .innerJoin( 131 + collectionCards, 132 + eq(collections.id, collectionCards.collectionId), 133 + ) 134 + .where( 135 + and( 136 + eq(collections.authorId, curatorId), 137 + eq(collectionCards.cardId, cardId), 138 + ), 139 + ) 140 + .orderBy(asc(collections.name)); 141 + 142 + return collectionResults.map((result) => ({ 143 + id: result.id, 144 + uri: result.uri || undefined, 145 + name: result.name, 146 + description: result.description || undefined, 147 + })); 148 + } catch (error) { 149 + console.error('Error in getCollectionsContainingCardForUser:', error); 107 150 throw error; 108 151 } 109 152 }
+53 -52
src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts
··· 33 33 34 34 interface NoteContentData { 35 35 text: string; 36 - authorId: string; 37 36 } 38 37 39 38 type CardContentData = UrlContentData | NoteContentData; 40 39 40 + // Persistence layer types 41 + export interface CardPersistenceData { 42 + card: { 43 + id: string; 44 + authorId: string; 45 + type: string; 46 + contentData: CardContentData; 47 + url?: string; 48 + parentCardId?: string; 49 + libraryCount: number; 50 + createdAt: Date; 51 + updatedAt: Date; 52 + }; 53 + libraryMemberships: Array<{ 54 + cardId: string; 55 + userId: string; 56 + addedAt: Date; 57 + publishedRecordId?: string; 58 + }>; 59 + publishedRecord?: { 60 + id: string; 61 + uri: string; 62 + cid: string; 63 + recordedAt?: Date; 64 + }; 65 + membershipPublishedRecords?: Array<{ 66 + id: string; 67 + uri: string; 68 + cid: string; 69 + recordedAt?: Date; 70 + }>; 71 + } 72 + 41 73 // Raw data for URL card queries 42 74 export interface RawUrlCardData { 43 75 id: string; ··· 60 92 // Database representation of a card 61 93 export interface CardDTO { 62 94 id: string; 95 + curatorId: string; 63 96 type: string; 64 97 contentData: CardContentData; // Type-safe JSON data for the content 65 98 url?: string; 66 99 parentCardId?: string; 67 - originalPublishedRecordId?: { 100 + publishedRecordId?: { 68 101 uri: string; 69 102 cid: string; 70 103 }; ··· 84 117 export class CardMapper { 85 118 public static toDomain(dto: CardDTO): Result<Card> { 86 119 try { 120 + const curatorIdOrError = CuratorId.create(dto.curatorId); 121 + if (curatorIdOrError.isErr()) return err(curatorIdOrError.error); 122 + 87 123 const cardTypeOrError = CardType.create(dto.type as CardTypeEnum); 88 124 if (cardTypeOrError.isErr()) return err(cardTypeOrError.error); 89 125 ··· 110 146 parentCardId = parentCardIdOrError.value; 111 147 } 112 148 113 - // Create optional original published record ID 114 - let originalPublishedRecordId: PublishedRecordId | undefined; 115 - if (dto.originalPublishedRecordId) { 116 - originalPublishedRecordId = PublishedRecordId.create({ 117 - uri: dto.originalPublishedRecordId.uri, 118 - cid: dto.originalPublishedRecordId.cid, 149 + // Create optional published record ID 150 + let publishedRecordId: PublishedRecordId | undefined; 151 + if (dto.publishedRecordId) { 152 + publishedRecordId = PublishedRecordId.create({ 153 + uri: dto.publishedRecordId.uri, 154 + cid: dto.publishedRecordId.cid, 119 155 }); 120 156 } 121 157 const libraryMemberships = dto.libraryMemberships.map((membership) => { ··· 145 181 // Create the card 146 182 const cardOrError = Card.create( 147 183 { 184 + curatorId: curatorIdOrError.value, 148 185 type: cardTypeOrError.value, 149 186 content: contentOrError.value, 150 187 url, 151 188 parentCardId, 152 - originalPublishedRecordId, 189 + publishedRecordId, 153 190 libraryMemberships, 154 191 libraryCount: dto.libraryCount, 155 192 createdAt: dto.createdAt, ··· 206 243 207 244 case CardTypeEnum.NOTE: { 208 245 const noteData = data as NoteContentData; 209 - const authorIdResult = CuratorId.create(noteData.authorId); 210 - if (authorIdResult.isErr()) return err(authorIdResult.error); 211 - return CardContent.createNoteContent( 212 - noteData.text, 213 - authorIdResult.value, 214 - ); 246 + return CardContent.createNoteContent(noteData.text); 215 247 } 216 248 217 249 default: ··· 222 254 } 223 255 } 224 256 225 - public static toPersistence(card: Card): { 226 - card: { 227 - id: string; 228 - type: string; 229 - contentData: CardContentData; 230 - url?: string; 231 - parentCardId?: string; 232 - libraryCount: number; 233 - createdAt: Date; 234 - updatedAt: Date; 235 - }; 236 - libraryMemberships: Array<{ 237 - cardId: string; 238 - userId: string; 239 - addedAt: Date; 240 - publishedRecordId?: string; 241 - }>; 242 - originalPublishedRecord?: { 243 - id: string; 244 - uri: string; 245 - cid: string; 246 - recordedAt?: Date; 247 - }; 248 - membershipPublishedRecords?: Array<{ 249 - id: string; 250 - uri: string; 251 - cid: string; 252 - recordedAt?: Date; 253 - }>; 254 - } { 257 + public static toPersistence(card: Card): CardPersistenceData { 255 258 const content = card.content; 256 259 let contentData: CardContentData; 257 260 ··· 276 279 } as UrlContentData; 277 280 } else if (content.type === CardTypeEnum.NOTE) { 278 281 const noteContent = content.noteContent!; 279 - // For note content, we need to get the author ID from the content 280 - // Since NoteCardContent now has authorId, we need to access it 281 282 contentData = { 282 283 text: noteContent.text, 283 - authorId: (noteContent as any).props.authorId.value, // Access the authorId from props 284 284 } as NoteContentData; 285 285 } else { 286 286 throw new Error(`Unknown card type: ${content.type}`); 287 287 } 288 288 289 289 // Collect all published records that need to be created 290 - const originalPublishedRecord = card.originalPublishedRecordId 290 + const publishedRecord = card.publishedRecordId 291 291 ? { 292 292 id: uuid(), 293 - uri: card.originalPublishedRecordId.uri, 294 - cid: card.originalPublishedRecordId.cid, 293 + uri: card.publishedRecordId.uri, 294 + cid: card.publishedRecordId.cid, 295 295 } 296 296 : undefined; 297 297 ··· 325 325 return { 326 326 card: { 327 327 id: card.cardId.getStringValue(), 328 + authorId: card.curatorId.value, 328 329 type: card.type.value, 329 330 contentData, 330 331 url: card.url?.value, ··· 334 335 updatedAt: card.updatedAt, 335 336 }, 336 337 libraryMemberships, 337 - originalPublishedRecord, 338 + publishedRecord, 338 339 membershipPublishedRecords: 339 340 membershipPublishedRecords.length > 0 340 341 ? membershipPublishedRecords
+27 -13
src/modules/cards/infrastructure/repositories/schema/card.sql.ts
··· 5 5 jsonb, 6 6 uuid, 7 7 integer, 8 + index, 8 9 type PgTableWithColumns, 9 10 } from 'drizzle-orm/pg-core'; 10 11 import { publishedRecords } from './publishedRecord.sql'; 11 12 12 - export const cards: PgTableWithColumns<any> = pgTable('cards', { 13 - id: uuid('id').primaryKey(), 14 - type: text('type').notNull(), // URL, NOTE, HIGHLIGHT 15 - contentData: jsonb('content_data').notNull(), 16 - url: text('url'), // Optional URL field for all card types 17 - parentCardId: uuid('parent_card_id').references(() => cards.id), 18 - originalPublishedRecordId: uuid('original_published_record_id').references( 19 - () => publishedRecords.id, 20 - ), 21 - libraryCount: integer('library_count').notNull().default(0), 22 - createdAt: timestamp('created_at').notNull().defaultNow(), 23 - updatedAt: timestamp('updated_at').notNull().defaultNow(), 24 - }); 13 + export const cards: PgTableWithColumns<any> = pgTable( 14 + 'cards', 15 + { 16 + id: uuid('id').primaryKey(), 17 + authorId: text('author_id').notNull(), 18 + type: text('type').notNull(), // URL, NOTE, HIGHLIGHT 19 + contentData: jsonb('content_data').notNull(), 20 + url: text('url'), // Optional URL field for all card types 21 + parentCardId: uuid('parent_card_id').references(() => cards.id), 22 + publishedRecordId: uuid('published_record_id').references( 23 + () => publishedRecords.id, 24 + ), 25 + libraryCount: integer('library_count').notNull().default(0), 26 + createdAt: timestamp('created_at').notNull().defaultNow(), 27 + updatedAt: timestamp('updated_at').notNull().defaultNow(), 28 + }, 29 + (table) => { 30 + return { 31 + // Critical for findUsersUrlCardByUrl queries 32 + authorUrlIdx: index('cards_author_url_idx').on(table.authorId, table.url), 33 + 34 + // For general card queries by author 35 + authorIdIdx: index('cards_author_id_idx').on(table.authorId), 36 + }; 37 + }, 38 + );
+55 -27
src/modules/cards/infrastructure/repositories/schema/collection.sql.ts
··· 5 5 uuid, 6 6 boolean, 7 7 integer, 8 + index, 8 9 } from 'drizzle-orm/pg-core'; 9 10 import { publishedRecords } from './publishedRecord.sql'; 10 11 import { cards } from './card.sql'; 11 12 12 - export const collections = pgTable('collections', { 13 - id: uuid('id').primaryKey(), 14 - authorId: text('author_id').notNull(), 15 - name: text('name').notNull(), 16 - description: text('description'), 17 - accessType: text('access_type').notNull(), // OPEN, CLOSED 18 - cardCount: integer('card_count').notNull().default(0), 19 - createdAt: timestamp('created_at').notNull().defaultNow(), 20 - updatedAt: timestamp('updated_at').notNull().defaultNow(), 21 - publishedRecordId: uuid('published_record_id').references( 22 - () => publishedRecords.id, 23 - ), 24 - }); 13 + export const collections = pgTable( 14 + 'collections', 15 + { 16 + id: uuid('id').primaryKey(), 17 + authorId: text('author_id').notNull(), 18 + name: text('name').notNull(), 19 + description: text('description'), 20 + accessType: text('access_type').notNull(), // OPEN, CLOSED 21 + cardCount: integer('card_count').notNull().default(0), 22 + createdAt: timestamp('created_at').notNull().defaultNow(), 23 + updatedAt: timestamp('updated_at').notNull().defaultNow(), 24 + publishedRecordId: uuid('published_record_id').references( 25 + () => publishedRecords.id, 26 + ), 27 + }, 28 + (table) => { 29 + return { 30 + // Critical for all collection queries by user 31 + authorIdIdx: index('collections_author_id_idx').on(table.authorId), 32 + 33 + // For paginated collection listings (most common sort) 34 + authorUpdatedAtIdx: index('collections_author_updated_at_idx').on( 35 + table.authorId, 36 + table.updatedAt, 37 + ), 38 + }; 39 + }, 40 + ); 25 41 26 42 // Join table for collection collaborators 27 43 export const collectionCollaborators = pgTable('collection_collaborators', { ··· 33 49 }); 34 50 35 51 // Join table for cards in collections 36 - export const collectionCards = pgTable('collection_cards', { 37 - id: uuid('id').primaryKey(), 38 - collectionId: uuid('collection_id') 39 - .notNull() 40 - .references(() => collections.id, { onDelete: 'cascade' }), 41 - cardId: uuid('card_id') 42 - .notNull() 43 - .references(() => cards.id, { onDelete: 'cascade' }), 44 - addedBy: text('added_by').notNull(), 45 - addedAt: timestamp('added_at').notNull().defaultNow(), 46 - publishedRecordId: uuid('published_record_id').references( 47 - () => publishedRecords.id, 48 - ), 49 - }); 52 + export const collectionCards = pgTable( 53 + 'collection_cards', 54 + { 55 + id: uuid('id').primaryKey(), 56 + collectionId: uuid('collection_id') 57 + .notNull() 58 + .references(() => collections.id, { onDelete: 'cascade' }), 59 + cardId: uuid('card_id') 60 + .notNull() 61 + .references(() => cards.id, { onDelete: 'cascade' }), 62 + addedBy: text('added_by').notNull(), 63 + addedAt: timestamp('added_at').notNull().defaultNow(), 64 + publishedRecordId: uuid('published_record_id').references( 65 + () => publishedRecords.id, 66 + ), 67 + }, 68 + (table) => { 69 + return { 70 + // Critical for getCollectionsContainingCardForUser queries 71 + cardIdIdx: index('collection_cards_card_id_idx').on(table.cardId), 72 + collectionIdIdx: index('collection_cards_collection_id_idx').on( 73 + table.collectionId, 74 + ), 75 + }; 76 + }, 77 + );
+18 -11
src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts
··· 9 9 import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 10 10 import { CardBuilder } from '../utils/builders/CardBuilder'; 11 11 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 12 + import { CARD_ERROR_MESSAGES } from '../../domain/Card'; 12 13 13 14 describe('AddCardToLibraryUseCase', () => { 14 15 let useCase: AddCardToLibraryUseCase; ··· 19 20 let cardLibraryService: CardLibraryService; 20 21 let cardCollectionService: CardCollectionService; 21 22 let curatorId: CuratorId; 23 + let curatorId2: CuratorId; 22 24 23 25 beforeEach(() => { 24 26 cardRepository = new InMemoryCardRepository(); ··· 44 46 ); 45 47 46 48 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 49 + curatorId2 = CuratorId.create('did:plc:testcurator2').unwrap(); 47 50 }); 48 51 49 52 afterEach(() => { ··· 54 57 }); 55 58 56 59 describe('Basic card addition to library', () => { 57 - it('should add an existing card to library', async () => { 60 + it('should not allow adding an existing url card to library', async () => { 58 61 // Create and save a card first 59 62 const card = new CardBuilder() 60 63 .withCuratorId(curatorId.value) ··· 65 68 throw new Error(`Failed to create card: ${card.message}`); 66 69 } 67 70 71 + const addToLibResult = card.addToLibrary(curatorId); 72 + if (addToLibResult.isErr()) { 73 + throw new Error( 74 + `Failed to add card to library: ${addToLibResult.error.message}`, 75 + ); 76 + } 77 + 68 78 await cardRepository.save(card); 69 79 70 80 const request = { 71 81 cardId: card.cardId.getStringValue(), 72 - curatorId: curatorId.value, 82 + curatorId: curatorId2.value, 73 83 }; 74 84 75 85 const result = await useCase.execute(request); 76 86 77 - expect(result.isOk()).toBe(true); 78 - const response = result.unwrap(); 79 - expect(response.cardId).toBe(card.cardId.getStringValue()); 80 - 81 - // Verify card was published to library 82 - const publishedCards = cardPublisher.getPublishedCards(); 83 - expect(publishedCards).toHaveLength(1); 84 - expect(publishedCards[0]?.cardId.getStringValue()).toBe( 85 - card.cardId.getStringValue(), 87 + if (result.isOk()) { 88 + throw new Error('Expected use case to fail, but it succeeded'); 89 + } 90 + expect(result.isErr()).toBe(true); 91 + expect(result.error.message).toContain( 92 + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 86 93 ); 87 94 }); 88 95
+74
src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts
··· 188 188 ); 189 189 expect(urlCards).toHaveLength(1); // Only one URL card 190 190 }); 191 + 192 + it('should create new URL card when another user has URL card with same URL', async () => { 193 + const url = 'https://example.com/shared'; 194 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 195 + 196 + // First user creates URL card 197 + const firstRequest = { 198 + url, 199 + curatorId: otherCuratorId.value, 200 + }; 201 + 202 + const firstResult = await useCase.execute(firstRequest); 203 + expect(firstResult.isOk()).toBe(true); 204 + const firstResponse = firstResult.unwrap(); 205 + 206 + // Second user (different curator) should create their own URL card 207 + const secondRequest = { 208 + url, 209 + curatorId: curatorId.value, 210 + }; 211 + 212 + const secondResult = await useCase.execute(secondRequest); 213 + expect(secondResult.isOk()).toBe(true); 214 + const secondResponse = secondResult.unwrap(); 215 + 216 + // Should have different URL card IDs 217 + expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId); 218 + 219 + // Should have two separate URL cards 220 + const savedCards = cardRepository.getAllCards(); 221 + expect(savedCards).toHaveLength(2); 222 + 223 + const urlCards = savedCards.filter( 224 + (card) => card.content.type === CardTypeEnum.URL, 225 + ); 226 + expect(urlCards).toHaveLength(2); // Two separate URL cards 227 + 228 + // Verify each card belongs to the correct curator 229 + const firstUserCard = urlCards.find((card) => 230 + card.props.curatorId.equals(otherCuratorId), 231 + ); 232 + const secondUserCard = urlCards.find((card) => 233 + card.props.curatorId.equals(curatorId), 234 + ); 235 + 236 + expect(firstUserCard).toBeDefined(); 237 + expect(secondUserCard).toBeDefined(); 238 + }); 239 + 240 + it('should create new URL card when no one has URL card with that URL yet', async () => { 241 + const url = 'https://example.com/brand-new'; 242 + 243 + // Verify no cards exist initially 244 + expect(cardRepository.getAllCards()).toHaveLength(0); 245 + 246 + const request = { 247 + url, 248 + curatorId: curatorId.value, 249 + }; 250 + 251 + const result = await useCase.execute(request); 252 + 253 + expect(result.isOk()).toBe(true); 254 + const response = result.unwrap(); 255 + expect(response.urlCardId).toBeDefined(); 256 + 257 + // Verify new URL card was created 258 + const savedCards = cardRepository.getAllCards(); 259 + expect(savedCards).toHaveLength(1); 260 + 261 + const urlCard = savedCards[0]; 262 + expect(urlCard?.content.type).toBe(CardTypeEnum.URL); 263 + expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true); 264 + }); 191 265 }); 192 266 193 267 describe('Collection handling', () => {
+39 -58
src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts
··· 125 125 ).unwrap(); 126 126 127 127 const card1Result = Card.create({ 128 + curatorId: curatorId, 128 129 type: cardType1, 129 130 content: cardContent1, 130 131 url: url1, ··· 157 158 ).unwrap(); 158 159 159 160 const card2Result = Card.create({ 161 + curatorId: curatorId, 160 162 type: cardType2, 161 163 content: cardContent2, 162 164 url: url2, ··· 234 236 ).unwrap(); 235 237 236 238 const cardResult = Card.create({ 239 + curatorId: curatorId, 237 240 type: cardType, 238 241 content: cardContent, 239 242 url: url, ··· 252 255 253 256 // Create a note card that references the same URL 254 257 const noteCardResult = Card.create({ 258 + curatorId: curatorId, 255 259 type: CardType.create(CardTypeEnum.NOTE).unwrap(), 256 260 content: CardContent.createNoteContent( 257 261 'This is my note about the article', 258 - curatorId, 259 262 ).unwrap(), 260 263 parentCardId: card.cardId, 261 264 url: url, ··· 353 356 ).unwrap(); 354 357 355 358 const cardResult = Card.create({ 359 + curatorId: curatorId, 356 360 type: cardType, 357 361 content: cardContent, 358 362 url: url, ··· 461 465 // Create URL cards with different properties for sorting 462 466 const now = new Date(); 463 467 464 - // Create Alpha card 468 + // Create Alpha card (oldest created, middle updated) 465 469 const alphaMetadata = UrlMetadata.create({ 466 470 url: 'https://example.com/alpha', 467 471 title: 'Alpha Article', ··· 475 479 ).unwrap(); 476 480 477 481 const alphaCardResult = Card.create({ 482 + curatorId: curatorId, 478 483 type: alphaCardType, 479 484 content: alphaCardContent, 480 485 url: alphaUrl, ··· 482 487 { curatorId: curatorId, addedAt: new Date(now.getTime() - 5000) }, 483 488 ], 484 489 libraryCount: 1, 485 - createdAt: new Date(now.getTime() - 2000), 486 - updatedAt: new Date(now.getTime() - 1000), 490 + createdAt: new Date(now.getTime() - 3000), // oldest 491 + updatedAt: new Date(now.getTime() - 1000), // middle 487 492 }); 488 493 489 494 if (alphaCardResult.isErr()) { ··· 492 497 493 498 await cardRepo.save(alphaCardResult.value); 494 499 495 - // Create Beta card 500 + // Create Beta card (middle created, oldest updated) 496 501 const betaMetadata = UrlMetadata.create({ 497 502 url: 'https://example.com/beta', 498 503 title: 'Beta Article', ··· 506 511 ).unwrap(); 507 512 508 513 const betaCardResult = Card.create({ 514 + curatorId: curatorId, 509 515 type: betaCardType, 510 516 content: betaCardContent, 511 517 url: betaUrl, 512 518 libraryMemberships: [ 513 519 { curatorId: curatorId, addedAt: new Date(now.getTime() - 5000) }, 514 - { 515 - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), 516 - addedAt: new Date(now.getTime() - 2000), 517 - }, 518 - { 519 - curatorId: CuratorId.create('did:plc:thirdcurator').unwrap(), 520 - addedAt: new Date(now.getTime() - 1000), 521 - }, 522 520 ], 523 - libraryCount: 3, 524 - createdAt: new Date(now.getTime() - 1000), 525 - updatedAt: new Date(now.getTime() - 2000), 521 + libraryCount: 1, 522 + createdAt: new Date(now.getTime() - 2000), // middle 523 + updatedAt: new Date(now.getTime() - 3000), // oldest 526 524 }); 527 525 528 526 if (betaCardResult.isErr()) { ··· 531 529 532 530 await cardRepo.save(betaCardResult.value); 533 531 534 - // Create Gamma card 532 + // Create Gamma card (newest created, newest updated) 535 533 const gammaMetadata = UrlMetadata.create({ 536 534 url: 'https://example.com/gamma', 537 535 title: 'Gamma Article', ··· 545 543 ).unwrap(); 546 544 547 545 const gammaCardResult = Card.create({ 546 + curatorId: curatorId, 548 547 type: gammaCardType, 549 548 content: gammaCardContent, 550 549 url: gammaUrl, 551 550 libraryMemberships: [ 552 551 { curatorId: curatorId, addedAt: new Date(now.getTime() - 3000) }, 553 - { 554 - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), 555 - addedAt: new Date(now.getTime() - 1000), 556 - }, 557 552 ], 558 - libraryCount: 2, 559 - createdAt: new Date(now.getTime()), 560 - updatedAt: new Date(now.getTime()), 553 + libraryCount: 1, 554 + createdAt: new Date(now.getTime() - 1000), // newest 555 + updatedAt: new Date(now.getTime()), // newest 561 556 }); 562 557 563 558 if (gammaCardResult.isErr()) { ··· 573 568 await collectionRepo.save(collection); 574 569 }); 575 570 576 - it('should sort by library count descending', async () => { 571 + it('should sort by updated date descending', async () => { 577 572 const query = { 578 573 collectionId: collectionId.getStringValue(), 579 - sortBy: CardSortField.LIBRARY_COUNT, 574 + sortBy: CardSortField.UPDATED_AT, 580 575 sortOrder: SortOrder.DESC, 581 576 }; 582 577 ··· 584 579 585 580 expect(result.isOk()).toBe(true); 586 581 const response = result.unwrap(); 587 - expect(response.urlCards).toHaveLength(3); 588 - expect(response.urlCards[0]?.libraryCount).toBe(3); // beta 589 - expect(response.urlCards[1]?.libraryCount).toBe(2); // gamma 590 - expect(response.urlCards[2]?.libraryCount).toBe(1); // alpha 591 - expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); 592 - expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 582 + expect(response.urlCards[0]?.cardContent.title).toBe('Gamma Article'); // newest updated 583 + expect(response.urlCards[1]?.cardContent.title).toBe('Alpha Article'); // middle updated 584 + expect(response.urlCards[2]?.cardContent.title).toBe('Beta Article'); // oldest updated 593 585 }); 594 586 595 - it('should sort by library count ascending', async () => { 587 + it('should sort by created date ascending', async () => { 596 588 const query = { 597 589 collectionId: collectionId.getStringValue(), 598 - sortBy: CardSortField.LIBRARY_COUNT, 590 + sortBy: CardSortField.CREATED_AT, 599 591 sortOrder: SortOrder.ASC, 600 592 }; 601 593 ··· 603 595 604 596 expect(result.isOk()).toBe(true); 605 597 const response = result.unwrap(); 606 - expect(response.urlCards[0]?.libraryCount).toBe(1); // alpha 607 - expect(response.urlCards[1]?.libraryCount).toBe(2); // gamma 608 - expect(response.urlCards[2]?.libraryCount).toBe(3); // beta 598 + expect(response.urlCards[0]?.cardContent.title).toBe('Alpha Article'); // oldest created 599 + expect(response.urlCards[1]?.cardContent.title).toBe('Beta Article'); // middle created 600 + expect(response.urlCards[2]?.cardContent.title).toBe('Gamma Article'); // newest created 609 601 }); 610 602 611 - it('should sort by updated date descending', async () => { 603 + it('should sort by library count (all URL cards have same count)', async () => { 612 604 const query = { 613 605 collectionId: collectionId.getStringValue(), 614 - sortBy: CardSortField.UPDATED_AT, 606 + sortBy: CardSortField.LIBRARY_COUNT, 615 607 sortOrder: SortOrder.DESC, 616 608 }; 617 609 ··· 619 611 620 612 expect(result.isOk()).toBe(true); 621 613 const response = result.unwrap(); 622 - expect(response.urlCards[0]?.cardContent.title).toBe('Gamma Article'); // most recent 623 - expect(response.urlCards[1]?.cardContent.title).toBe('Alpha Article'); 624 - expect(response.urlCards[2]?.cardContent.title).toBe('Beta Article'); // oldest 625 - }); 626 - 627 - it('should sort by created date ascending', async () => { 628 - const query = { 629 - collectionId: collectionId.getStringValue(), 630 - sortBy: CardSortField.CREATED_AT, 631 - sortOrder: SortOrder.ASC, 632 - }; 633 - 634 - const result = await useCase.execute(query); 635 - 636 - expect(result.isOk()).toBe(true); 637 - const response = result.unwrap(); 638 - expect(response.urlCards[0]?.cardContent.title).toBe('Alpha Article'); // oldest 639 - expect(response.urlCards[1]?.cardContent.title).toBe('Beta Article'); 640 - expect(response.urlCards[2]?.cardContent.title).toBe('Gamma Article'); // newest 614 + expect(response.urlCards).toHaveLength(3); 615 + // All URL cards have library count of 1 (only creator's library) 616 + expect(response.urlCards[0]?.libraryCount).toBe(1); 617 + expect(response.urlCards[1]?.libraryCount).toBe(1); 618 + expect(response.urlCards[2]?.libraryCount).toBe(1); 619 + expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); 620 + expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 641 621 }); 642 622 643 623 it('should use default sorting when not specified', async () => { ··· 823 803 const cardContent = CardContent.createUrlContent(url).unwrap(); 824 804 825 805 const cardResult = Card.create({ 806 + curatorId: curatorId, 826 807 type: cardType, 827 808 content: cardContent, 828 809 url: url,
+23 -25
src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts
··· 72 72 73 73 const cardResult1 = Card.create( 74 74 { 75 + curatorId: curatorId, 75 76 type: cardType1, 76 77 content: cardContent1, 77 78 url: url1, ··· 106 107 107 108 const cardResult2 = Card.create( 108 109 { 110 + curatorId: curatorId, 109 111 type: cardType2, 110 112 content: cardContent2, 111 113 url: url2, 112 114 libraryMemberships: [ 113 115 { curatorId: curatorId, addedAt: new Date('2023-01-02') }, 114 - { 115 - curatorId: CuratorId.create('did:plc:othercurator').unwrap(), 116 - addedAt: new Date('2023-01-03'), 117 - }, 118 116 ], 117 + libraryCount: 1, 119 118 createdAt: new Date('2023-01-02'), 120 119 updatedAt: new Date('2023-01-02'), 121 120 }, ··· 155 154 156 155 expect(secondCard).toBeDefined(); 157 156 expect(secondCard?.cardContent.title).toBe('Second Article'); 158 - expect(secondCard?.libraryCount).toBe(2); 157 + expect(secondCard?.libraryCount).toBe(1); 159 158 }); 160 159 161 160 it('should include collections and notes in URL cards', async () => { ··· 179 178 // Create card 180 179 const cardResult = Card.create( 181 180 { 181 + curatorId: curatorId, 182 182 type: cardType, 183 183 content: cardContent, 184 184 url: url, ··· 234 234 235 235 const myCardResult = Card.create( 236 236 { 237 + curatorId: curatorId, 237 238 type: myCardType, 238 239 content: myCardContent, 239 240 url: myUrl, ··· 264 265 265 266 const otherCardResult = Card.create( 266 267 { 268 + curatorId: otherCuratorId, 267 269 type: otherCardType, 268 270 content: otherCardContent, 269 271 url: otherUrl, ··· 316 318 317 319 const cardResult = Card.create( 318 320 { 321 + curatorId: curatorId, 319 322 type: cardType, 320 323 content: cardContent, 321 324 url: url, ··· 425 428 426 429 const alphaCardResult = Card.create( 427 430 { 431 + curatorId: curatorId, 428 432 type: alphaCardType, 429 433 content: alphaCardContent, 430 434 url: alphaUrl, ··· 457 461 458 462 const betaCardResult = Card.create( 459 463 { 464 + curatorId: curatorId, 460 465 type: betaCardType, 461 466 content: betaCardContent, 462 467 url: betaUrl, 463 468 libraryMemberships: [ 464 469 { curatorId: curatorId, addedAt: new Date(now.getTime() - 1000) }, 465 - { 466 - curatorId: CuratorId.create('did:plc:othercurator').unwrap(), 467 - addedAt: new Date(now.getTime() - 500), 468 - }, 469 - { 470 - curatorId: CuratorId.create('did:plc:thirdcurator').unwrap(), 471 - addedAt: new Date(now.getTime() - 300), 472 - }, 473 470 ], 474 - libraryCount: 3, 471 + libraryCount: 1, 475 472 createdAt: new Date(now.getTime() - 1000), 476 473 updatedAt: new Date(now.getTime() - 2000), 477 474 }, ··· 497 494 498 495 const gammaCardResult = Card.create( 499 496 { 497 + curatorId: curatorId, 500 498 type: gammaCardType, 501 499 content: gammaCardContent, 502 500 url: gammaUrl, 503 501 libraryMemberships: [ 504 502 { curatorId: curatorId, addedAt: new Date(now.getTime()) }, 505 - { 506 - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), 507 - addedAt: new Date(now.getTime() - 100), 508 - }, 509 503 ], 510 - libraryCount: 2, 504 + libraryCount: 1, 511 505 createdAt: new Date(now.getTime()), 512 506 updatedAt: new Date(now.getTime()), 513 507 }, ··· 535 529 expect(result.isOk()).toBe(true); 536 530 const response = result.unwrap(); 537 531 expect(response.cards).toHaveLength(3); 538 - expect(response.cards[0]?.libraryCount).toBe(3); // beta 539 - expect(response.cards[1]?.libraryCount).toBe(2); // gamma 540 - expect(response.cards[2]?.libraryCount).toBe(1); // alpha 532 + // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) 533 + expect(response.cards[0]?.libraryCount).toBe(1); // gamma (newest) 534 + expect(response.cards[1]?.libraryCount).toBe(1); // beta 535 + expect(response.cards[2]?.libraryCount).toBe(1); // alpha (oldest) 541 536 expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); 542 537 expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 543 538 }); ··· 553 548 554 549 expect(result.isOk()).toBe(true); 555 550 const response = result.unwrap(); 556 - expect(response.cards[0]?.libraryCount).toBe(1); // alpha 557 - expect(response.cards[1]?.libraryCount).toBe(2); // gamma 558 - expect(response.cards[2]?.libraryCount).toBe(3); // beta 551 + // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) 552 + expect(response.cards[0]?.libraryCount).toBe(1); // alpha (oldest) 553 + expect(response.cards[1]?.libraryCount).toBe(1); // beta 554 + expect(response.cards[2]?.libraryCount).toBe(1); // gamma (newest) 559 555 }); 560 556 561 557 it('should sort by updated date descending', async () => { ··· 661 657 662 658 const cardResult = Card.create( 663 659 { 660 + curatorId: curatorId, 664 661 type: cardType, 665 662 content: cardContent, 666 663 url: url, ··· 709 706 710 707 const cardResult = Card.create( 711 708 { 709 + curatorId: curatorId, 712 710 type: cardType, 713 711 content: cardContent, 714 712 url: url,
+29 -33
src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts
··· 4 4 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5 5 import { FakeProfileService } from '../utils/FakeProfileService'; 6 6 import { CuratorId } from '../../domain/value-objects/CuratorId'; 7 - import { CardId } from '../../domain/value-objects/CardId'; 8 7 import { Card } from '../../domain/Card'; 9 8 import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; 10 9 import { CardContent } from '../../domain/value-objects/CardContent'; ··· 81 80 // Create card with library memberships 82 81 const cardResult = Card.create( 83 82 { 83 + curatorId: curatorId, 84 84 type: cardType, 85 85 content: cardContent, 86 86 url: url, 87 87 libraryMemberships: [ 88 88 { curatorId: curatorId, addedAt: new Date('2023-01-01') }, 89 - { curatorId: otherCuratorId, addedAt: new Date('2023-01-01') }, 90 89 ], 91 - libraryCount: 2, 90 + libraryCount: 1, 92 91 createdAt: new Date('2023-01-01'), 93 92 updatedAt: new Date('2023-01-01'), 94 93 }, ··· 104 103 105 104 // now create a note card that references this URL card 106 105 const noteCardResult = Card.create({ 106 + curatorId: curatorId, 107 107 type: CardType.create(CardTypeEnum.NOTE).unwrap(), 108 108 content: CardContent.createNoteContent( 109 109 'This is my note about the article', 110 - curatorId, 111 110 ).unwrap(), 112 111 parentCardId: card.cardId, 113 112 url: url, ··· 152 151 expect(response.cardContent.thumbnailUrl).toBe( 153 152 'https://example.com/thumb1.jpg', 154 153 ); 155 - expect(response.libraryCount).toBe(2); 154 + expect(response.libraryCount).toBe(1); 156 155 157 156 // Verify collections 158 157 expect(response.collections).toHaveLength(1); ··· 164 163 expect(response.note?.text).toBe('This is my note about the article'); 165 164 166 165 // Verify enriched library data 167 - expect(response.libraries).toHaveLength(2); 166 + expect(response.libraries).toHaveLength(1); 168 167 169 168 const testCuratorLib = response.libraries.find( 170 169 (lib) => lib.userId === curatorId.value, ··· 173 172 expect(testCuratorLib?.name).toBe('Test Curator'); 174 173 expect(testCuratorLib?.handle).toBe('testcurator'); 175 174 expect(testCuratorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg'); 176 - 177 - const otherCuratorLib = response.libraries.find( 178 - (lib) => lib.userId === otherCuratorId.value, 179 - ); 180 - expect(otherCuratorLib).toBeDefined(); 181 - expect(otherCuratorLib?.name).toBe('Other Curator'); 182 - expect(otherCuratorLib?.handle).toBe('othercurator'); 183 - expect(otherCuratorLib?.avatarUrl).toBe( 184 - 'https://example.com/avatar2.jpg', 185 - ); 186 175 }); 187 176 188 177 it('should return URL card view with no libraries', async () => { ··· 204 193 // Create card with no library memberships 205 194 const cardResult = Card.create( 206 195 { 196 + curatorId: curatorId, 207 197 type: cardType, 208 198 content: cardContent, 209 199 url: url, ··· 244 234 // Create card with minimal data 245 235 const cardResult = Card.create( 246 236 { 237 + curatorId: curatorId, 247 238 type: cardType, 248 239 content: cardContent, 249 240 url: url, ··· 304 295 // Create card 305 296 const cardResult = Card.create( 306 297 { 298 + curatorId: minimalCuratorId, 307 299 type: cardType, 308 300 content: cardContent, 309 301 url: url, ··· 386 378 // Create card 387 379 const cardResult = Card.create( 388 380 { 381 + curatorId: curatorId, 389 382 type: cardType, 390 383 content: cardContent, 391 384 url: url, ··· 440 433 // Create card with unknown user in library 441 434 const cardResult = Card.create( 442 435 { 436 + curatorId: unknownCuratorId, 443 437 type: cardType, 444 438 content: cardContent, 445 439 url: url, ··· 533 527 urlMetadata, 534 528 ).unwrap(); 535 529 536 - // Create card with multiple library memberships 530 + // Create card with single library membership (URL cards can only be in creator's library) 537 531 const cardResult = Card.create( 538 532 { 533 + curatorId: curatorIds[0]!, 539 534 type: cardType, 540 535 content: cardContent, 541 536 url: url, 542 - libraryMemberships: curatorIds.map((curatorId) => ({ 543 - curatorId: curatorId, 544 - addedAt: new Date(), 545 - })), 546 - libraryCount: 5, 537 + libraryMemberships: [ 538 + { 539 + curatorId: curatorIds[0]!, 540 + addedAt: new Date(), 541 + }, 542 + ], 543 + libraryCount: 1, 547 544 createdAt: new Date(), 548 545 updatedAt: new Date(), 549 546 }, ··· 565 562 566 563 expect(result.isOk()).toBe(true); 567 564 const response = result.unwrap(); 568 - expect(response.libraries).toHaveLength(5); 565 + expect(response.libraries).toHaveLength(1); 569 566 570 - // Verify all users are included with correct profile data 571 - userIds.forEach((userId, index) => { 572 - const userLib = response.libraries.find((lib) => lib.userId === userId); 573 - expect(userLib).toBeDefined(); 574 - expect(userLib?.name).toBe(`User ${index + 1}`); 575 - expect(userLib?.handle).toBe(`user${index + 1}`); 576 - expect(userLib?.avatarUrl).toBe( 577 - `https://example.com/avatar${index + 1}.jpg`, 578 - ); 579 - }); 567 + // Verify the creator is included with correct profile data 568 + const creatorLib = response.libraries.find( 569 + (lib) => lib.userId === userIds[0], 570 + ); 571 + expect(creatorLib).toBeDefined(); 572 + expect(creatorLib?.name).toBe('User 1'); 573 + expect(creatorLib?.handle).toBe('user1'); 574 + expect(creatorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg'); 580 575 }); 581 576 582 577 it('should handle card with many collections', async () => { ··· 597 592 // Create card 598 593 const cardResult = Card.create( 599 594 { 595 + curatorId: curatorId, 600 596 type: cardType, 601 597 content: cardContent, 602 598 url: url,
+576
src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts
··· 1 + import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 2 + import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3 + import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 4 + import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; 5 + import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 6 + import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 7 + import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 8 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 9 + import { CardBuilder } from '../utils/builders/CardBuilder'; 10 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 11 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 12 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 13 + import { URL } from '../../domain/value-objects/URL'; 14 + import { err } from 'src/shared/core/Result'; 15 + 16 + describe('GetUrlStatusForMyLibraryUseCase', () => { 17 + let useCase: GetUrlStatusForMyLibraryUseCase; 18 + let cardRepository: InMemoryCardRepository; 19 + let collectionRepository: InMemoryCollectionRepository; 20 + let collectionQueryRepository: InMemoryCollectionQueryRepository; 21 + let cardPublisher: FakeCardPublisher; 22 + let collectionPublisher: FakeCollectionPublisher; 23 + let eventPublisher: FakeEventPublisher; 24 + let curatorId: CuratorId; 25 + let otherCuratorId: CuratorId; 26 + 27 + beforeEach(() => { 28 + cardRepository = new InMemoryCardRepository(); 29 + collectionRepository = new InMemoryCollectionRepository(); 30 + collectionQueryRepository = new InMemoryCollectionQueryRepository( 31 + collectionRepository, 32 + ); 33 + cardPublisher = new FakeCardPublisher(); 34 + collectionPublisher = new FakeCollectionPublisher(); 35 + eventPublisher = new FakeEventPublisher(); 36 + 37 + useCase = new GetUrlStatusForMyLibraryUseCase( 38 + cardRepository, 39 + collectionQueryRepository, 40 + eventPublisher, 41 + ); 42 + 43 + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 44 + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 45 + }); 46 + 47 + afterEach(() => { 48 + cardRepository.clear(); 49 + collectionRepository.clear(); 50 + collectionQueryRepository.clear(); 51 + cardPublisher.clear(); 52 + collectionPublisher.clear(); 53 + eventPublisher.clear(); 54 + }); 55 + 56 + describe('URL card in collections', () => { 57 + it('should return card ID and collections when user has URL card in multiple collections', async () => { 58 + const testUrl = 'https://example.com/test-article'; 59 + 60 + // Create a URL card 61 + const url = URL.create(testUrl).unwrap(); 62 + const card = new CardBuilder() 63 + .withCuratorId(curatorId.value) 64 + .withType(CardTypeEnum.URL) 65 + .withUrl(url) 66 + .build(); 67 + 68 + if (card instanceof Error) { 69 + throw new Error(`Failed to create card: ${card.message}`); 70 + } 71 + 72 + // Add card to library 73 + const addToLibResult = card.addToLibrary(curatorId); 74 + if (addToLibResult.isErr()) { 75 + throw new Error( 76 + `Failed to add card to library: ${addToLibResult.error.message}`, 77 + ); 78 + } 79 + 80 + await cardRepository.save(card); 81 + 82 + // Publish the card to simulate it being published 83 + cardPublisher.publishCardToLibrary(card, curatorId); 84 + 85 + // Create first collection 86 + const collection1 = new CollectionBuilder() 87 + .withAuthorId(curatorId.value) 88 + .withName('Tech Articles') 89 + .withDescription('Collection of technology articles') 90 + .build(); 91 + 92 + if (collection1 instanceof Error) { 93 + throw new Error(`Failed to create collection1: ${collection1.message}`); 94 + } 95 + 96 + // Create second collection 97 + const collection2 = new CollectionBuilder() 98 + .withAuthorId(curatorId.value) 99 + .withName('Reading List') 100 + .withDescription('My personal reading list') 101 + .build(); 102 + 103 + if (collection2 instanceof Error) { 104 + throw new Error(`Failed to create collection2: ${collection2.message}`); 105 + } 106 + 107 + // Add card to both collections 108 + const addToCollection1Result = collection1.addCard( 109 + card.cardId, 110 + curatorId, 111 + ); 112 + if (addToCollection1Result.isErr()) { 113 + throw new Error( 114 + `Failed to add card to collection1: ${addToCollection1Result.error.message}`, 115 + ); 116 + } 117 + 118 + const addToCollection2Result = collection2.addCard( 119 + card.cardId, 120 + curatorId, 121 + ); 122 + if (addToCollection2Result.isErr()) { 123 + throw new Error( 124 + `Failed to add card to collection2: ${addToCollection2Result.error.message}`, 125 + ); 126 + } 127 + 128 + // Mark collections as published 129 + const collection1PublishedRecordId = PublishedRecordId.create({ 130 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', 131 + cid: 'bafyreicollection1cid', 132 + }); 133 + 134 + const collection2PublishedRecordId = PublishedRecordId.create({ 135 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', 136 + cid: 'bafyreicollection2cid', 137 + }); 138 + 139 + collection1.markAsPublished(collection1PublishedRecordId); 140 + collection2.markAsPublished(collection2PublishedRecordId); 141 + 142 + // Mark card links as published in collections 143 + const cardLinkPublishedRecordId1 = PublishedRecordId.create({ 144 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', 145 + cid: 'bafyreilink1cid', 146 + }); 147 + 148 + const cardLinkPublishedRecordId2 = PublishedRecordId.create({ 149 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', 150 + cid: 'bafyreilink2cid', 151 + }); 152 + 153 + collection1.markCardLinkAsPublished( 154 + card.cardId, 155 + cardLinkPublishedRecordId1, 156 + ); 157 + collection2.markCardLinkAsPublished( 158 + card.cardId, 159 + cardLinkPublishedRecordId2, 160 + ); 161 + 162 + // Save collections 163 + await collectionRepository.save(collection1); 164 + await collectionRepository.save(collection2); 165 + 166 + // Publish collections and links 167 + collectionPublisher.publish(collection1); 168 + collectionPublisher.publish(collection2); 169 + collectionPublisher.publishCardAddedToCollection( 170 + card, 171 + collection1, 172 + curatorId, 173 + ); 174 + collectionPublisher.publishCardAddedToCollection( 175 + card, 176 + collection2, 177 + curatorId, 178 + ); 179 + 180 + // Execute the use case 181 + const query = { 182 + url: testUrl, 183 + curatorId: curatorId.value, 184 + }; 185 + 186 + const result = await useCase.execute(query); 187 + 188 + // Verify the result 189 + expect(result.isOk()).toBe(true); 190 + const response = result.unwrap(); 191 + 192 + expect(response.cardId).toBe(card.cardId.getStringValue()); 193 + expect(response.collections).toHaveLength(2); 194 + 195 + // Verify collection details 196 + const techArticlesCollection = response.collections?.find( 197 + (c) => c.name === 'Tech Articles', 198 + ); 199 + const readingListCollection = response.collections?.find( 200 + (c) => c.name === 'Reading List', 201 + ); 202 + 203 + expect(techArticlesCollection).toBeDefined(); 204 + expect(techArticlesCollection?.id).toBe( 205 + collection1.collectionId.getStringValue(), 206 + ); 207 + expect(techArticlesCollection?.uri).toBe( 208 + 'at://did:plc:testcurator/network.cosmik.collection/collection1', 209 + ); 210 + expect(techArticlesCollection?.name).toBe('Tech Articles'); 211 + expect(techArticlesCollection?.description).toBe( 212 + 'Collection of technology articles', 213 + ); 214 + 215 + expect(readingListCollection).toBeDefined(); 216 + expect(readingListCollection?.id).toBe( 217 + collection2.collectionId.getStringValue(), 218 + ); 219 + expect(readingListCollection?.uri).toBe( 220 + 'at://did:plc:testcurator/network.cosmik.collection/collection2', 221 + ); 222 + expect(readingListCollection?.name).toBe('Reading List'); 223 + expect(readingListCollection?.description).toBe( 224 + 'My personal reading list', 225 + ); 226 + }); 227 + 228 + it('should return card ID and empty collections when user has URL card but not in any collections', async () => { 229 + const testUrl = 'https://example.com/standalone-article'; 230 + 231 + // Create a URL card 232 + const url = URL.create(testUrl).unwrap(); 233 + const card = new CardBuilder() 234 + .withCuratorId(curatorId.value) 235 + .withType(CardTypeEnum.URL) 236 + .withUrl(url) 237 + .build(); 238 + 239 + if (card instanceof Error) { 240 + throw new Error(`Failed to create card: ${card.message}`); 241 + } 242 + 243 + // Add card to library 244 + const addToLibResult = card.addToLibrary(curatorId); 245 + if (addToLibResult.isErr()) { 246 + throw new Error( 247 + `Failed to add card to library: ${addToLibResult.error.message}`, 248 + ); 249 + } 250 + 251 + await cardRepository.save(card); 252 + 253 + // Publish the card 254 + cardPublisher.publishCardToLibrary(card, curatorId); 255 + 256 + // Execute the use case 257 + const query = { 258 + url: testUrl, 259 + curatorId: curatorId.value, 260 + }; 261 + 262 + const result = await useCase.execute(query); 263 + 264 + // Verify the result 265 + expect(result.isOk()).toBe(true); 266 + const response = result.unwrap(); 267 + 268 + expect(response.cardId).toBe(card.cardId.getStringValue()); 269 + expect(response.collections).toHaveLength(0); 270 + }); 271 + 272 + it('should return empty result when user does not have URL card for the URL', async () => { 273 + const testUrl = 'https://example.com/nonexistent-article'; 274 + 275 + // Execute the use case without creating any cards 276 + const query = { 277 + url: testUrl, 278 + curatorId: curatorId.value, 279 + }; 280 + 281 + const result = await useCase.execute(query); 282 + 283 + // Verify the result 284 + expect(result.isOk()).toBe(true); 285 + const response = result.unwrap(); 286 + 287 + expect(response.cardId).toBeUndefined(); 288 + expect(response.collections).toBeUndefined(); 289 + }); 290 + 291 + it('should not return collections from other users even if they have the same URL', async () => { 292 + const testUrl = 'https://example.com/shared-article'; 293 + 294 + // Create URL card for first user 295 + const url = URL.create(testUrl).unwrap(); 296 + const card1 = new CardBuilder() 297 + .withCuratorId(curatorId.value) 298 + .withType(CardTypeEnum.URL) 299 + .withUrl(url) 300 + .build(); 301 + 302 + if (card1 instanceof Error) { 303 + throw new Error(`Failed to create card1: ${card1.message}`); 304 + } 305 + 306 + const addToLibResult1 = card1.addToLibrary(curatorId); 307 + if (addToLibResult1.isErr()) { 308 + throw new Error( 309 + `Failed to add card1 to library: ${addToLibResult1.error.message}`, 310 + ); 311 + } 312 + 313 + await cardRepository.save(card1); 314 + 315 + // Create URL card for second user (different card, same URL) 316 + const card2 = new CardBuilder() 317 + .withCuratorId(otherCuratorId.value) 318 + .withType(CardTypeEnum.URL) 319 + .withUrl(url) 320 + .build(); 321 + 322 + if (card2 instanceof Error) { 323 + throw new Error(`Failed to create card2: ${card2.message}`); 324 + } 325 + 326 + const addToLibResult2 = card2.addToLibrary(otherCuratorId); 327 + if (addToLibResult2.isErr()) { 328 + throw new Error( 329 + `Failed to add card2 to library: ${addToLibResult2.error.message}`, 330 + ); 331 + } 332 + 333 + await cardRepository.save(card2); 334 + 335 + // Create collection for second user and add their card 336 + const otherUserCollection = new CollectionBuilder() 337 + .withAuthorId(otherCuratorId.value) 338 + .withName('Other User Collection') 339 + .build(); 340 + 341 + if (otherUserCollection instanceof Error) { 342 + throw new Error( 343 + `Failed to create other user collection: ${otherUserCollection.message}`, 344 + ); 345 + } 346 + 347 + const addToOtherCollectionResult = otherUserCollection.addCard( 348 + card2.cardId, 349 + otherCuratorId, 350 + ); 351 + if (addToOtherCollectionResult.isErr()) { 352 + throw new Error( 353 + `Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`, 354 + ); 355 + } 356 + 357 + await collectionRepository.save(otherUserCollection); 358 + 359 + // Execute the use case for first user 360 + const query = { 361 + url: testUrl, 362 + curatorId: curatorId.value, 363 + }; 364 + 365 + const result = await useCase.execute(query); 366 + 367 + // Verify the result - should only see first user's card, no collections 368 + expect(result.isOk()).toBe(true); 369 + const response = result.unwrap(); 370 + 371 + expect(response.cardId).toBe(card1.cardId.getStringValue()); 372 + expect(response.collections).toHaveLength(0); // No collections for first user 373 + }); 374 + 375 + it('should only return collections owned by the requesting user', async () => { 376 + const testUrl = 'https://example.com/multi-user-article'; 377 + 378 + // Create URL card for the user 379 + const url = URL.create(testUrl).unwrap(); 380 + const card = new CardBuilder() 381 + .withCuratorId(curatorId.value) 382 + .withType(CardTypeEnum.URL) 383 + .withUrl(url) 384 + .build(); 385 + 386 + if (card instanceof Error) { 387 + throw new Error(`Failed to create card: ${card.message}`); 388 + } 389 + 390 + const addToLibResult = card.addToLibrary(curatorId); 391 + if (addToLibResult.isErr()) { 392 + throw new Error( 393 + `Failed to add card to library: ${addToLibResult.error.message}`, 394 + ); 395 + } 396 + 397 + await cardRepository.save(card); 398 + 399 + // Create user's own collection 400 + const userCollection = new CollectionBuilder() 401 + .withAuthorId(curatorId.value) 402 + .withName('My Collection') 403 + .build(); 404 + 405 + if (userCollection instanceof Error) { 406 + throw new Error( 407 + `Failed to create user collection: ${userCollection.message}`, 408 + ); 409 + } 410 + 411 + const addToUserCollectionResult = userCollection.addCard( 412 + card.cardId, 413 + curatorId, 414 + ); 415 + if (addToUserCollectionResult.isErr()) { 416 + throw new Error( 417 + `Failed to add card to user collection: ${addToUserCollectionResult.error.message}`, 418 + ); 419 + } 420 + 421 + await collectionRepository.save(userCollection); 422 + 423 + // Create another user's collection (this should not appear in results) 424 + const otherUserCollection = new CollectionBuilder() 425 + .withAuthorId(otherCuratorId.value) 426 + .withName('Other User Collection') 427 + .build(); 428 + 429 + if (otherUserCollection instanceof Error) { 430 + throw new Error( 431 + `Failed to create other user collection: ${otherUserCollection.message}`, 432 + ); 433 + } 434 + 435 + // Note: We don't add the card to the other user's collection since they can't add 436 + // another user's card to their collection in this domain model 437 + 438 + await collectionRepository.save(otherUserCollection); 439 + 440 + // Execute the use case 441 + const query = { 442 + url: testUrl, 443 + curatorId: curatorId.value, 444 + }; 445 + 446 + const result = await useCase.execute(query); 447 + 448 + // Verify the result - should only see user's own collection 449 + expect(result.isOk()).toBe(true); 450 + const response = result.unwrap(); 451 + 452 + expect(response.cardId).toBe(card.cardId.getStringValue()); 453 + expect(response.collections).toHaveLength(1); 454 + expect(response.collections?.[0]?.name).toBe('My Collection'); 455 + expect(response.collections?.[0]?.id).toBe( 456 + userCollection.collectionId.getStringValue(), 457 + ); 458 + }); 459 + }); 460 + 461 + describe('Validation', () => { 462 + it('should fail with invalid URL', async () => { 463 + const query = { 464 + url: 'not-a-valid-url', 465 + curatorId: curatorId.value, 466 + }; 467 + 468 + const result = await useCase.execute(query); 469 + 470 + expect(result.isErr()).toBe(true); 471 + if (result.isErr()) { 472 + expect(result.error.message).toContain('Invalid URL'); 473 + } 474 + }); 475 + 476 + it('should fail with invalid curator ID', async () => { 477 + const query = { 478 + url: 'https://example.com/valid-url', 479 + curatorId: 'invalid-curator-id', 480 + }; 481 + 482 + const result = await useCase.execute(query); 483 + 484 + expect(result.isErr()).toBe(true); 485 + if (result.isErr()) { 486 + expect(result.error.message).toContain('Invalid curator ID'); 487 + } 488 + }); 489 + }); 490 + 491 + describe('Error handling', () => { 492 + it('should handle repository errors gracefully', async () => { 493 + // Create a mock repository that returns an error Result 494 + const errorCardRepository = { 495 + findUsersUrlCardByUrl: jest 496 + .fn() 497 + .mockResolvedValue(err(new Error('Database error'))), 498 + save: jest.fn(), 499 + findById: jest.fn(), 500 + delete: jest.fn(), 501 + findByUrl: jest.fn(), 502 + findByCuratorId: jest.fn(), 503 + findByParentCardId: jest.fn(), 504 + }; 505 + 506 + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 507 + errorCardRepository, 508 + collectionQueryRepository, 509 + eventPublisher, 510 + ); 511 + 512 + const query = { 513 + url: 'https://example.com/test-url', 514 + curatorId: curatorId.value, 515 + }; 516 + 517 + const result = await errorUseCase.execute(query); 518 + 519 + expect(result.isErr()).toBe(true); 520 + if (result.isErr()) { 521 + expect(result.error.message).toContain('Database error'); 522 + } 523 + }); 524 + 525 + it('should handle collection query repository errors gracefully', async () => { 526 + const testUrl = 'https://example.com/error-test'; 527 + 528 + // Create a URL card 529 + const url = URL.create(testUrl).unwrap(); 530 + const card = new CardBuilder() 531 + .withCuratorId(curatorId.value) 532 + .withType(CardTypeEnum.URL) 533 + .withUrl(url) 534 + .build(); 535 + 536 + if (card instanceof Error) { 537 + throw new Error(`Failed to create card: ${card.message}`); 538 + } 539 + 540 + const addToLibResult = card.addToLibrary(curatorId); 541 + if (addToLibResult.isErr()) { 542 + throw new Error( 543 + `Failed to add card to library: ${addToLibResult.error.message}`, 544 + ); 545 + } 546 + 547 + await cardRepository.save(card); 548 + 549 + // Create a mock collection query repository that throws an error 550 + const errorCollectionQueryRepository = { 551 + findByCreator: jest.fn(), 552 + getCollectionsContainingCardForUser: jest 553 + .fn() 554 + .mockRejectedValue(new Error('Collection query error')), 555 + }; 556 + 557 + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 558 + cardRepository, 559 + errorCollectionQueryRepository, 560 + eventPublisher, 561 + ); 562 + 563 + const query = { 564 + url: testUrl, 565 + curatorId: curatorId.value, 566 + }; 567 + 568 + const result = await errorUseCase.execute(query); 569 + 570 + expect(result.isErr()).toBe(true); 571 + if (result.isErr()) { 572 + expect(result.error.message).toContain('Collection query error'); 573 + } 574 + }); 575 + }); 576 + });
+141 -69
src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts
··· 51 51 cardPublisher.clear(); 52 52 }); 53 53 54 - const createCard = async (type: CardTypeEnum = CardTypeEnum.URL) => { 55 - const card = new CardBuilder().withType(type).build(); 54 + const createCard = async ( 55 + type: CardTypeEnum = CardTypeEnum.URL, 56 + creatorId: CuratorId = curatorId, 57 + ) => { 58 + const card = new CardBuilder() 59 + .withType(type) 60 + .withCuratorId(creatorId.value) 61 + .build(); 56 62 57 63 if (card instanceof Error) { 58 64 throw new Error(`Failed to create card: ${card.message}`); ··· 63 69 }; 64 70 65 71 const addCardToLibrary = async (card: any, curatorId: CuratorId) => { 72 + // For URL cards, can only add to creator's library 73 + if (card.isUrlCard && !card.curatorId.equals(curatorId)) { 74 + throw new Error("URL cards can only be added to creator's library"); 75 + } 76 + 66 77 const addResult = await cardLibraryService.addCardToLibrary( 67 78 card, 68 79 curatorId, ··· 126 137 const response = result.unwrap(); 127 138 expect(response.cardId).toBe(card.cardId.getStringValue()); 128 139 129 - // Verify card was removed from library 140 + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 130 141 const updatedCardResult = await cardRepository.findById(card.cardId); 131 - const updatedCard = updatedCardResult.unwrap()!; 132 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 142 + const updatedCard = updatedCardResult.unwrap(); 143 + expect(updatedCard).toBeNull(); 133 144 134 145 // Verify unpublish operation occurred 135 146 const unpublishedCards = cardPublisher.getUnpublishedCards(); 136 147 expect(unpublishedCards).toHaveLength(1); 137 148 }); 138 149 139 - it("should remove card from one user's library without affecting others", async () => { 150 + it("should remove URL card from creator's library", async () => { 140 151 const card = await createCard(); 141 152 142 - // Add card to both users' libraries 153 + // Add card to creator's library only (URL cards can only be in creator's library) 143 154 await addCardToLibrary(card, curatorId); 144 - await addCardToLibrary(card, otherCuratorId); 145 155 146 156 const request = { 147 157 cardId: card.cardId.getStringValue(), ··· 152 162 153 163 expect(result.isOk()).toBe(true); 154 164 155 - // Verify card was removed from curatorId's library but not otherCuratorId's 165 + // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) 156 166 const updatedCardResult = await cardRepository.findById(card.cardId); 157 - const updatedCard = updatedCardResult.unwrap()!; 158 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 159 - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); 167 + const updatedCard = updatedCardResult.unwrap(); 168 + expect(updatedCard).toBeNull(); 160 169 161 - // Verify only one unpublish operation occurred 170 + // Verify unpublish operation occurred 162 171 const unpublishedCards = cardPublisher.getUnpublishedCards(); 163 172 expect(unpublishedCards).toHaveLength(1); 164 173 }); 165 174 166 175 it('should handle different card types', async () => { 167 - const urlCard = await createCard(CardTypeEnum.URL); 168 - const noteCard = await createCard(CardTypeEnum.NOTE); 176 + // Create URL card with curatorId as creator 177 + const urlCard = await createCard(CardTypeEnum.URL, curatorId); 178 + // Create note card with curatorId as creator 179 + const noteCard = await createCard(CardTypeEnum.NOTE, curatorId); 169 180 170 181 await addCardToLibrary(urlCard, curatorId); 171 182 await addCardToLibrary(noteCard, curatorId); ··· 188 199 const noteResult = await useCase.execute(noteRequest); 189 200 expect(noteResult.isOk()).toBe(true); 190 201 191 - // Verify both cards were removed 202 + // Verify both cards were removed from library and deleted (since they're no longer in any libraries and curator is owner) 192 203 const updatedUrlCardResult = await cardRepository.findById( 193 204 urlCard.cardId, 194 205 ); ··· 196 207 noteCard.cardId, 197 208 ); 198 209 199 - const updatedUrlCard = updatedUrlCardResult.unwrap()!; 200 - const updatedNoteCard = updatedNoteCardResult.unwrap()!; 210 + const updatedUrlCard = updatedUrlCardResult.unwrap(); 211 + const updatedNoteCard = updatedNoteCardResult.unwrap(); 201 212 202 - expect(updatedUrlCard.isInLibrary(curatorId)).toBe(false); 203 - expect(updatedNoteCard.isInLibrary(curatorId)).toBe(false); 213 + expect(updatedUrlCard).toBeNull(); 214 + expect(updatedNoteCard).toBeNull(); 204 215 }); 205 216 }); 206 217 ··· 323 334 const finalUnpublishCount = cardPublisher.getUnpublishedCards().length; 324 335 expect(finalUnpublishCount).toBe(initialUnpublishCount); 325 336 326 - // Verify card was still removed from library 337 + // Verify card was still removed from library and deleted (since it's no longer in any libraries and curator is owner) 327 338 const updatedCardResult = await cardRepository.findById(card.cardId); 328 - const updatedCard = updatedCardResult.unwrap()!; 329 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 339 + const updatedCard = updatedCardResult.unwrap(); 340 + expect(updatedCard).toBeNull(); 330 341 }); 331 342 }); 332 343 ··· 382 393 383 394 expect(result.isOk()).toBe(true); 384 395 385 - // Verify card was removed from library 396 + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 386 397 const updatedCardResult = await cardRepository.findById(card.cardId); 387 - const updatedCard = updatedCardResult.unwrap()!; 388 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 398 + const updatedCard = updatedCardResult.unwrap(); 399 + expect(updatedCard).toBeNull(); 389 400 390 401 // Verify card was removed from all collections 391 402 const finalCollection1Result = await collectionRepository.findById( ··· 419 430 expect(unpublishedCollectionLinks).toHaveLength(3); 420 431 }); 421 432 422 - it('should only remove card from curator collections, not other curator collections', async () => { 433 + it('should remove URL card from creator collections only', async () => { 423 434 const card = await createCard(); 424 435 await addCardToLibrary(card, curatorId); 425 - await addCardToLibrary(card, otherCuratorId); 426 436 427 - // Create collections for both curators 437 + // Create collections for the creator only (URL cards can only be in creator's library) 428 438 const curatorCollection = await createCollection( 429 439 curatorId, 430 440 'Curator Collection', 431 441 ); 432 - const otherCuratorCollection = await createCollection( 433 - otherCuratorId, 434 - 'Other Curator Collection', 435 - ); 436 442 437 - // Add card to both collections 443 + // Add card to creator's collection 438 444 await addCardToCollection(card, curatorCollection, curatorId); 439 - await addCardToCollection(card, otherCuratorCollection, otherCuratorId); 440 445 441 - // Remove card from first curator's library 446 + // Remove card from creator's library 442 447 const request = { 443 448 cardId: card.cardId.getStringValue(), 444 449 curatorId: curatorId.value, ··· 448 453 449 454 expect(result.isOk()).toBe(true); 450 455 451 - // Verify card was removed from curator's collection but not other curator's collection 456 + // Verify card was removed from curator's collection 452 457 const curatorCollectionResult = await collectionRepository.findById( 453 458 curatorCollection.collectionId, 454 459 ); 455 - const otherCuratorCollectionResult = await collectionRepository.findById( 456 - otherCuratorCollection.collectionId, 457 - ); 458 460 459 461 expect( 460 462 curatorCollectionResult 461 463 .unwrap()! 462 464 .cardIds.some((id) => id.equals(card.cardId)), 463 465 ).toBe(false); 464 - expect( 465 - otherCuratorCollectionResult 466 - .unwrap()! 467 - .cardIds.some((id) => id.equals(card.cardId)), 468 - ).toBe(true); 469 466 470 - // Verify card is still in other curator's library 467 + // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) 471 468 const updatedCardResult = await cardRepository.findById(card.cardId); 472 - const updatedCard = updatedCardResult.unwrap()!; 473 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 474 - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); 469 + const updatedCard = updatedCardResult.unwrap(); 470 + expect(updatedCard).toBeNull(); 475 471 }); 476 472 477 473 it('should handle card removal when no collections contain the card', async () => { ··· 491 487 492 488 expect(result.isOk()).toBe(true); 493 489 494 - // Verify card was removed from library 490 + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 495 491 const updatedCardResult = await cardRepository.findById(card.cardId); 496 - const updatedCard = updatedCardResult.unwrap()!; 497 - expect(updatedCard.isInLibrary(curatorId)).toBe(false); 492 + const updatedCard = updatedCardResult.unwrap(); 493 + expect(updatedCard).toBeNull(); 498 494 499 495 // Verify no collection unpublish operations occurred 500 496 const unpublishedCollections = ··· 503 499 }); 504 500 }); 505 501 506 - describe('Edge cases', () => { 507 - it('should handle card with multiple library memberships', async () => { 502 + describe('Card deletion behavior', () => { 503 + it('should delete card when removed from last library and curator is owner', async () => { 508 504 const card = await createCard(); 505 + await addCardToLibrary(card, curatorId); 509 506 510 - // Add to multiple users' libraries 507 + // Verify card exists and is in library 508 + expect(card.isInLibrary(curatorId)).toBe(true); 509 + expect(card.libraryMembershipCount).toBe(1); 510 + 511 + const request = { 512 + cardId: card.cardId.getStringValue(), 513 + curatorId: curatorId.value, 514 + }; 515 + 516 + const result = await useCase.execute(request); 517 + 518 + expect(result.isOk()).toBe(true); 519 + 520 + // Verify card was deleted 521 + const cardResult = await cardRepository.findById(card.cardId); 522 + const cardFromRepo = cardResult.unwrap(); 523 + expect(cardFromRepo).toBeNull(); 524 + }); 525 + 526 + it('should not delete card when curator is not the owner', async () => { 527 + // Create card with different owner 528 + const card = await createCard(CardTypeEnum.NOTE, otherCuratorId); 529 + 530 + // Add to other curator's library first 531 + await addCardToLibrary(card, otherCuratorId); 532 + 533 + // Add to current curator's library (note cards can be in multiple libraries) 534 + await addCardToLibrary(card, curatorId); 535 + 536 + // Remove from current curator's library 537 + const request = { 538 + cardId: card.cardId.getStringValue(), 539 + curatorId: curatorId.value, 540 + }; 541 + 542 + const result = await useCase.execute(request); 543 + 544 + expect(result.isOk()).toBe(true); 545 + 546 + // Verify card still exists (not deleted because curator is not owner) 547 + const cardResult = await cardRepository.findById(card.cardId); 548 + const cardFromRepo = cardResult.unwrap()!; 549 + expect(cardFromRepo).not.toBeNull(); 550 + expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); 551 + expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); 552 + }); 553 + 554 + it('should not delete card when it still has other library memberships', async () => { 555 + // Create note card (can be in multiple libraries) 556 + const card = await createCard(CardTypeEnum.NOTE, curatorId); 557 + 558 + // Add to both curator's libraries 511 559 await addCardToLibrary(card, curatorId); 512 560 await addCardToLibrary(card, otherCuratorId); 513 561 514 562 expect(card.libraryMembershipCount).toBe(2); 515 563 564 + // Remove from curator's library 516 565 const request = { 517 566 cardId: card.cardId.getStringValue(), 518 567 curatorId: curatorId.value, ··· 522 571 523 572 expect(result.isOk()).toBe(true); 524 573 525 - // Verify card still has one library membership 574 + // Verify card still exists (not deleted because it's still in other curator's library) 575 + const cardResult = await cardRepository.findById(card.cardId); 576 + const cardFromRepo = cardResult.unwrap()!; 577 + expect(cardFromRepo).not.toBeNull(); 578 + expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); 579 + expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); 580 + expect(cardFromRepo.libraryMembershipCount).toBe(1); 581 + }); 582 + }); 583 + 584 + describe('Edge cases', () => { 585 + it('should handle URL card with single library membership', async () => { 586 + // Create URL card with curatorId as creator 587 + const card = await createCard(CardTypeEnum.URL, curatorId); 588 + 589 + // Add to creator's library only (URL cards can only be in creator's library) 590 + await addCardToLibrary(card, curatorId); 591 + 592 + expect(card.libraryMembershipCount).toBe(1); 593 + 594 + const request = { 595 + cardId: card.cardId.getStringValue(), 596 + curatorId: curatorId.value, 597 + }; 598 + 599 + const result = await useCase.execute(request); 600 + 601 + expect(result.isOk()).toBe(true); 602 + 603 + // Verify card was deleted (since it's no longer in any libraries and curator is owner) 526 604 const updatedCardResult = await cardRepository.findById(card.cardId); 527 - const updatedCard = updatedCardResult.unwrap()!; 528 - expect(updatedCard.libraryMembershipCount).toBe(1); 529 - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); 605 + const updatedCard = updatedCardResult.unwrap(); 606 + expect(updatedCard).toBeNull(); 530 607 }); 531 608 532 609 it('should handle repository save failure', async () => { ··· 547 624 }); 548 625 549 626 it('should preserve card properties when removing from library', async () => { 550 - const card = await createCard(); 627 + // Create URL card with curatorId as creator 628 + const card = await createCard(CardTypeEnum.URL, curatorId); 551 629 await addCardToLibrary(card, curatorId); 552 630 553 631 const originalCreatedAt = card.createdAt; ··· 563 641 564 642 expect(result.isOk()).toBe(true); 565 643 566 - // Verify card properties are preserved 644 + // Verify card was deleted (since it's no longer in any libraries and curator is owner) 567 645 const updatedCardResult = await cardRepository.findById(card.cardId); 568 - const updatedCard = updatedCardResult.unwrap()!; 569 - 570 - expect(updatedCard.createdAt).toEqual(originalCreatedAt); 571 - expect(updatedCard.type.value).toBe(originalType); 572 - expect(updatedCard.content).toEqual(originalContent); 573 - expect(updatedCard.updatedAt.getTime()).toBeGreaterThanOrEqual( 574 - originalCreatedAt.getTime(), 575 - ); 646 + const updatedCard = updatedCardResult.unwrap(); 647 + expect(updatedCard).toBeNull(); 576 648 }); 577 649 }); 578 650 });
+1 -3
src/modules/cards/tests/application/UpdateNoteCardUseCase.test.ts
··· 141 141 originalCreatedAt.getTime(), 142 142 ); 143 143 expect(updatedCard.type.value).toBe(CardTypeEnum.NOTE); 144 - expect(updatedCard.content.noteContent!.authorId.equals(curatorId)).toBe( 145 - true, 146 - ); 144 + expect(updatedCard.curatorId.equals(curatorId)).toBe(true); 147 145 }); 148 146 }); 149 147
+325
src/modules/cards/tests/domain/Card.test.ts
··· 1 + import { Card, CARD_ERROR_MESSAGES } from '../../domain/Card'; 2 + import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; 3 + import { CardContent } from '../../domain/value-objects/CardContent'; 4 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 5 + import { URL } from '../../domain/value-objects/URL'; 6 + import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; 7 + 8 + describe('Card', () => { 9 + describe('create', () => { 10 + it('should create URL card without automatically adding to library', () => { 11 + // Arrange 12 + const curatorId = CuratorId.create('did:plc:test123').unwrap(); 13 + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 14 + const url = URL.create('https://example.com').unwrap(); 15 + const cardContent = CardContent.createUrlContent( 16 + url, 17 + UrlMetadata.create({ 18 + url: url.toString(), 19 + title: 'Test Title', 20 + description: 'Test Description', 21 + }).unwrap(), 22 + ).unwrap(); 23 + 24 + // Act 25 + const result = Card.create({ 26 + curatorId, 27 + type: cardType, 28 + content: cardContent, 29 + url, 30 + }); 31 + 32 + // Assert 33 + expect(result.isOk()).toBe(true); 34 + const card = result.unwrap(); 35 + 36 + // Verify card is NOT automatically in curator's library 37 + expect(card.isInLibrary(curatorId)).toBe(false); 38 + expect(card.libraryMembershipCount).toBe(0); 39 + expect(card.libraryCount).toBe(0); 40 + }); 41 + 42 + it('should create note card without automatically adding to library', () => { 43 + // Arrange 44 + const curatorId = CuratorId.create('did:plc:test456').unwrap(); 45 + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 46 + const cardContent = CardContent.createNoteContent( 47 + 'This is a test note', 48 + ).unwrap(); 49 + 50 + // Act 51 + const result = Card.create({ 52 + curatorId, 53 + type: cardType, 54 + content: cardContent, 55 + }); 56 + 57 + // Assert 58 + expect(result.isOk()).toBe(true); 59 + const card = result.unwrap(); 60 + 61 + // Verify card is NOT automatically in curator's library 62 + expect(card.isInLibrary(curatorId)).toBe(false); 63 + expect(card.libraryMembershipCount).toBe(0); 64 + expect(card.libraryCount).toBe(0); 65 + }); 66 + 67 + it('should fail to create URL card with library membership different from creator', () => { 68 + // Arrange 69 + const curatorId = CuratorId.create('did:plc:test789').unwrap(); 70 + const otherUserId = CuratorId.create('did:plc:other123').unwrap(); 71 + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 72 + const url = URL.create('https://example.com').unwrap(); 73 + const cardContent = CardContent.createUrlContent( 74 + url, 75 + UrlMetadata.create({ 76 + url: url.toString(), 77 + title: 'Test Title', 78 + description: 'Test Description', 79 + }).unwrap(), 80 + ).unwrap(); 81 + 82 + const existingMemberships = [ 83 + { 84 + curatorId: otherUserId, 85 + addedAt: new Date('2023-01-01'), 86 + publishedRecordId: undefined, 87 + }, 88 + ]; 89 + 90 + // Act 91 + const result = Card.create({ 92 + curatorId, 93 + type: cardType, 94 + content: cardContent, 95 + url, 96 + libraryMemberships: existingMemberships, 97 + libraryCount: 1, 98 + }); 99 + 100 + // Assert 101 + if (result.isOk()) { 102 + throw new Error('Expected creation to fail but it succeeded'); 103 + } 104 + expect(result.isErr()).toBe(true); 105 + expect(result.error.message).toBe( 106 + CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY, 107 + ); 108 + }); 109 + 110 + it('should fail to create URL card with multiple library memberships', () => { 111 + // Arrange 112 + const curatorId = CuratorId.create('did:plc:test789').unwrap(); 113 + const otherUserId1 = CuratorId.create('did:plc:other123').unwrap(); 114 + const otherUserId2 = CuratorId.create('did:plc:other456').unwrap(); 115 + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 116 + const url = URL.create('https://example.com').unwrap(); 117 + const cardContent = CardContent.createUrlContent( 118 + url, 119 + UrlMetadata.create({ 120 + url: url.toString(), 121 + title: 'Test Title', 122 + description: 'Test Description', 123 + }).unwrap(), 124 + ).unwrap(); 125 + 126 + const existingMemberships = [ 127 + { 128 + curatorId: otherUserId1, 129 + addedAt: new Date('2023-01-01'), 130 + publishedRecordId: undefined, 131 + }, 132 + { 133 + curatorId: otherUserId2, 134 + addedAt: new Date('2023-01-02'), 135 + publishedRecordId: undefined, 136 + }, 137 + ]; 138 + 139 + // Act 140 + const result = Card.create({ 141 + curatorId, 142 + type: cardType, 143 + content: cardContent, 144 + url, 145 + libraryMemberships: existingMemberships, 146 + libraryCount: 2, 147 + }); 148 + 149 + // Assert 150 + if (result.isOk()) { 151 + throw new Error('Expected creation to fail but it succeeded'); 152 + } 153 + expect(result.isErr()).toBe(true); 154 + expect(result.error.message).toBe( 155 + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 156 + ); 157 + }); 158 + 159 + it('should allow NOTE cards to have multiple library memberships', () => { 160 + // Arrange 161 + const curatorId = CuratorId.create('did:plc:test789').unwrap(); 162 + const otherUserId = CuratorId.create('did:plc:other123').unwrap(); 163 + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 164 + const cardContent = CardContent.createNoteContent( 165 + 'This is a test note', 166 + ).unwrap(); 167 + 168 + const existingMemberships = [ 169 + { 170 + curatorId: otherUserId, 171 + addedAt: new Date('2023-01-01'), 172 + publishedRecordId: undefined, 173 + }, 174 + ]; 175 + 176 + // Act 177 + const result = Card.create({ 178 + curatorId, 179 + type: cardType, 180 + content: cardContent, 181 + libraryMemberships: existingMemberships, 182 + libraryCount: 1, 183 + }); 184 + 185 + // Assert 186 + expect(result.isOk()).toBe(true); 187 + const card = result.unwrap(); 188 + 189 + // Should have only the existing membership (no automatic curator addition) 190 + expect(card.libraryMembershipCount).toBe(1); 191 + expect(card.libraryCount).toBe(1); 192 + expect(card.isInLibrary(curatorId)).toBe(false); 193 + expect(card.isInLibrary(otherUserId)).toBe(true); 194 + }); 195 + }); 196 + 197 + describe('addToLibrary', () => { 198 + it('should allow adding URL card to first library', () => { 199 + // Arrange 200 + const curatorId = CuratorId.create('did:plc:test123').unwrap(); 201 + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 202 + const url = URL.create('https://example.com').unwrap(); 203 + const cardContent = CardContent.createUrlContent( 204 + url, 205 + UrlMetadata.create({ 206 + url: url.toString(), 207 + title: 'Test Title', 208 + description: 'Test Description', 209 + }).unwrap(), 210 + ).unwrap(); 211 + 212 + const card = Card.create({ 213 + curatorId, 214 + type: cardType, 215 + content: cardContent, 216 + url, 217 + }).unwrap(); 218 + 219 + // Act - add to curator's library 220 + const result = card.addToLibrary(curatorId); 221 + 222 + // Assert 223 + expect(result.isOk()).toBe(true); 224 + expect(card.libraryMembershipCount).toBe(1); 225 + expect(card.isInLibrary(curatorId)).toBe(true); 226 + }); 227 + 228 + it('should prevent adding URL card to multiple libraries', () => { 229 + // Arrange 230 + const curatorId = CuratorId.create('did:plc:test123').unwrap(); 231 + const otherUserId = CuratorId.create('did:plc:other456').unwrap(); 232 + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 233 + const url = URL.create('https://example.com').unwrap(); 234 + const cardContent = CardContent.createUrlContent( 235 + url, 236 + UrlMetadata.create({ 237 + url: url.toString(), 238 + title: 'Test Title', 239 + description: 'Test Description', 240 + }).unwrap(), 241 + ).unwrap(); 242 + 243 + const card = Card.create({ 244 + curatorId, 245 + type: cardType, 246 + content: cardContent, 247 + url, 248 + }).unwrap(); 249 + 250 + // First add to curator's library 251 + card.addToLibrary(curatorId); 252 + 253 + // Act - try to add to another user's library 254 + const result = card.addToLibrary(otherUserId); 255 + 256 + // Assert 257 + if (result.isOk()) { 258 + throw new Error('Expected creation to fail but it succeeded'); 259 + } 260 + expect(result.isErr()).toBe(true); 261 + expect(result.error.message).toBe( 262 + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 263 + ); 264 + expect(card.libraryMembershipCount).toBe(1); 265 + expect(card.isInLibrary(curatorId)).toBe(true); 266 + expect(card.isInLibrary(otherUserId)).toBe(false); 267 + }); 268 + 269 + it('should allow adding NOTE card to multiple libraries', () => { 270 + // Arrange 271 + const curatorId = CuratorId.create('did:plc:test123').unwrap(); 272 + const otherUserId = CuratorId.create('did:plc:other456').unwrap(); 273 + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 274 + const cardContent = CardContent.createNoteContent( 275 + 'This is a test note', 276 + ).unwrap(); 277 + 278 + const card = Card.create({ 279 + curatorId, 280 + type: cardType, 281 + content: cardContent, 282 + }).unwrap(); 283 + 284 + // Act - add to both libraries 285 + const result1 = card.addToLibrary(curatorId); 286 + const result2 = card.addToLibrary(otherUserId); 287 + 288 + // Assert 289 + expect(result1.isOk()).toBe(true); 290 + expect(result2.isOk()).toBe(true); 291 + expect(card.libraryMembershipCount).toBe(2); 292 + expect(card.isInLibrary(curatorId)).toBe(true); 293 + expect(card.isInLibrary(otherUserId)).toBe(true); 294 + }); 295 + 296 + it('should prevent adding same user twice to any card type', () => { 297 + // Arrange 298 + const curatorId = CuratorId.create('did:plc:test123').unwrap(); 299 + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 300 + const cardContent = CardContent.createNoteContent( 301 + 'This is a test note', 302 + ).unwrap(); 303 + 304 + const card = Card.create({ 305 + curatorId, 306 + type: cardType, 307 + content: cardContent, 308 + }).unwrap(); 309 + 310 + // First add to library 311 + card.addToLibrary(curatorId); 312 + 313 + // Act - try to add same user again 314 + const result = card.addToLibrary(curatorId); 315 + 316 + // Assert 317 + if (result.isOk()) { 318 + throw new Error('Expected creation to fail but it succeeded'); 319 + } 320 + expect(result.isErr()).toBe(true); 321 + expect(result.error.message).toBe(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY); 322 + expect(card.libraryMembershipCount).toBe(1); 323 + }); 324 + }); 325 + });
+40 -36
src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts
··· 77 77 const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 78 78 79 79 const cardResult = Card.create({ 80 + curatorId, 80 81 type: cardType, 81 82 content: urlContent, 82 83 url, ··· 109 110 // Create a note card 110 111 const noteContent = CardContent.createNoteContent( 111 112 'This is a test note', 112 - curatorId, 113 113 ).unwrap(); 114 114 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 115 115 116 116 const cardResult = Card.create({ 117 + curatorId, 117 118 type: cardType, 118 119 content: noteContent, 119 120 }); ··· 141 142 // Create a note card 142 143 const noteContent = CardContent.createNoteContent( 143 144 'Card with library memberships', 144 - curatorId, 145 145 ).unwrap(); 146 146 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 147 147 148 148 const cardResult = Card.create({ 149 + curatorId, 149 150 type: cardType, 150 151 content: noteContent, 151 152 }); ··· 183 184 // Create a note card 184 185 const noteContent = CardContent.createNoteContent( 185 186 'Card for membership updates', 186 - curatorId, 187 187 ).unwrap(); 188 188 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 189 189 190 190 const cardResult = Card.create({ 191 + curatorId, 191 192 type: cardType, 192 193 content: noteContent, 193 194 }); ··· 227 228 228 229 it('should delete a card and its library memberships', async () => { 229 230 // Create a card 230 - const noteContent = CardContent.createNoteContent( 231 - 'Card to delete', 232 - curatorId, 233 - ).unwrap(); 231 + const noteContent = 232 + CardContent.createNoteContent('Card to delete').unwrap(); 234 233 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 235 234 236 235 const cardResult = Card.create({ 236 + curatorId, 237 237 type: cardType, 238 238 content: noteContent, 239 239 }); ··· 261 261 }); 262 262 263 263 it('should return null when card is not found', async () => { 264 - const noteContent = CardContent.createNoteContent( 265 - 'Non-existent card', 266 - curatorId, 267 - ).unwrap(); 264 + const noteContent = 265 + CardContent.createNoteContent('Non-existent card').unwrap(); 268 266 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 269 267 270 268 const nonExistentCardId = Card.create({ 269 + curatorId, 271 270 type: cardType, 272 271 content: noteContent, 273 272 }).unwrap().cardId; ··· 277 276 expect(result.unwrap()).toBeNull(); 278 277 }); 279 278 280 - it('should handle originalPublishedRecordId when marking card as published', async () => { 279 + it('should handle publishedRecordId when marking card as published', async () => { 281 280 // Create a note card 282 281 const noteContent = CardContent.createNoteContent( 283 282 'Card for publishing test', 284 - curatorId, 285 283 ).unwrap(); 286 284 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 287 285 288 286 const cardResult = Card.create({ 287 + curatorId, 289 288 type: cardType, 290 289 content: noteContent, 291 290 }); ··· 295 294 // Add to library 296 295 card.addToLibrary(curatorId); 297 296 298 - // Mark as published - this should set the originalPublishedRecordId 297 + // Mark as published - this should set the publishedRecordId 299 298 const publishedRecordId = { 300 299 uri: 'at://did:plc:testcurator/network.cosmik.card/test123', 301 300 cid: 'bafytest123', ··· 309 308 ); 310 309 expect(markResult.isOk()).toBe(true); 311 310 312 - // Verify originalPublishedRecordId is set in memory 313 - expect(card.originalPublishedRecordId).toBeDefined(); 314 - expect(card.originalPublishedRecordId?.uri).toBe(publishedRecordId.uri); 315 - expect(card.originalPublishedRecordId?.cid).toBe(publishedRecordId.cid); 311 + // Verify publishedRecordId is set in memory 312 + expect(card.publishedRecordId).toBeDefined(); 313 + expect(card.publishedRecordId?.uri).toBe(publishedRecordId.uri); 314 + expect(card.publishedRecordId?.cid).toBe(publishedRecordId.cid); 316 315 317 - // Save the card - this should persist the originalPublishedRecordId 316 + // Save the card - this should persist the publishedRecordId 318 317 const saveResult = await cardRepository.save(card); 319 318 expect(saveResult.isOk()).toBe(true); 320 319 321 - // Retrieve and verify the originalPublishedRecordId persisted 320 + // Retrieve and verify the publishedRecordId persisted 322 321 const retrievedResult = await cardRepository.findById(card.cardId); 323 322 const retrievedCard = retrievedResult.unwrap(); 324 323 325 - expect(retrievedCard?.originalPublishedRecordId).toBeDefined(); 326 - expect(retrievedCard?.originalPublishedRecordId?.uri).toBe( 327 - publishedRecordId.uri, 328 - ); 329 - expect(retrievedCard?.originalPublishedRecordId?.cid).toBe( 330 - publishedRecordId.cid, 331 - ); 324 + expect(retrievedCard?.publishedRecordId).toBeDefined(); 325 + expect(retrievedCard?.publishedRecordId?.uri).toBe(publishedRecordId.uri); 326 + expect(retrievedCard?.publishedRecordId?.cid).toBe(publishedRecordId.cid); 332 327 }); 333 328 334 329 it('should find URL card by URL', async () => { ··· 345 340 const cardType = CardType.create(CardTypeEnum.URL).unwrap(); 346 341 347 342 const cardResult = Card.create({ 343 + curatorId, 348 344 type: cardType, 349 345 content: urlContent, 350 346 url, ··· 356 352 await cardRepository.save(card); 357 353 358 354 // Find the card by URL 359 - const foundResult = await cardRepository.findUrlCardByUrl(url); 355 + const foundResult = await cardRepository.findUsersUrlCardByUrl( 356 + url, 357 + curatorId, 358 + ); 360 359 expect(foundResult.isOk()).toBe(true); 361 360 362 361 const foundCard = foundResult.unwrap(); ··· 371 370 it('should return null when URL card is not found', async () => { 372 371 const nonExistentUrl = URL.create('https://example.com/notfound').unwrap(); 373 372 374 - const result = await cardRepository.findUrlCardByUrl(nonExistentUrl); 373 + const result = await cardRepository.findUsersUrlCardByUrl( 374 + nonExistentUrl, 375 + curatorId, 376 + ); 375 377 expect(result.isOk()).toBe(true); 376 378 expect(result.unwrap()).toBeNull(); 377 379 }); ··· 379 381 it('should not find note cards when searching by URL', async () => { 380 382 // Create a note card with a URL (but it's not a URL card type) 381 383 const url = URL.create('https://example.com/note-url').unwrap(); 382 - const noteContent = CardContent.createNoteContent( 383 - 'Note about a URL', 384 - curatorId, 385 - ).unwrap(); 384 + const noteContent = 385 + CardContent.createNoteContent('Note about a URL').unwrap(); 386 386 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 387 387 388 388 const cardResult = Card.create({ 389 + curatorId, 389 390 type: cardType, 390 391 content: noteContent, 391 392 url, // Note cards can have URLs too ··· 395 396 await cardRepository.save(card); 396 397 397 398 // Try to find it as a URL card - should return null because it's a NOTE type 398 - const foundResult = await cardRepository.findUrlCardByUrl(url); 399 + const foundResult = await cardRepository.findUsersUrlCardByUrl( 400 + url, 401 + curatorId, 402 + ); 399 403 expect(foundResult.isOk()).toBe(true); 400 404 expect(foundResult.unwrap()).toBeNull(); 401 405 }); ··· 404 408 // Create a note card 405 409 const noteContent = CardContent.createNoteContent( 406 410 'Card for library count test', 407 - curatorId, 408 411 ).unwrap(); 409 412 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 410 413 411 414 const cardResult = Card.create({ 415 + curatorId, 412 416 type: cardType, 413 417 content: noteContent, 414 418 }); ··· 459 463 // Create a note card with initial library memberships 460 464 const noteContent = CardContent.createNoteContent( 461 465 'Card with initial memberships', 462 - curatorId, 463 466 ).unwrap(); 464 467 const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); 465 468 ··· 475 478 ]; 476 479 477 480 const cardResult = Card.create({ 481 + curatorId, 478 482 type: cardType, 479 483 content: noteContent, 480 484 libraryMemberships: initialMemberships,
+798
src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts
··· 1 + import { 2 + PostgreSqlContainer, 3 + StartedPostgreSqlContainer, 4 + } from '@testcontainers/postgresql'; 5 + import postgres from 'postgres'; 6 + import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 7 + import { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository'; 8 + import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; 9 + import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; 10 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 11 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 12 + import { 13 + collections, 14 + collectionCollaborators, 15 + collectionCards, 16 + } from '../../infrastructure/repositories/schema/collection.sql'; 17 + import { cards } from '../../infrastructure/repositories/schema/card.sql'; 18 + import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 19 + import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 20 + import { Collection, CollectionAccessType } from '../../domain/Collection'; 21 + import { CardFactory } from '../../domain/CardFactory'; 22 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 23 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 24 + import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; 25 + import { createTestSchema } from '../test-utils/createTestSchema'; 26 + 27 + describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser', () => { 28 + let container: StartedPostgreSqlContainer; 29 + let db: PostgresJsDatabase; 30 + let queryRepository: DrizzleCollectionQueryRepository; 31 + let collectionRepository: DrizzleCollectionRepository; 32 + let cardRepository: DrizzleCardRepository; 33 + 34 + // Test data 35 + let curatorId: CuratorId; 36 + let otherCuratorId: CuratorId; 37 + 38 + // Setup before all tests 39 + beforeAll(async () => { 40 + // Start PostgreSQL container 41 + container = await new PostgreSqlContainer('postgres:14').start(); 42 + 43 + // Create database connection 44 + const connectionString = container.getConnectionUri(); 45 + process.env.DATABASE_URL = connectionString; 46 + const client = postgres(connectionString); 47 + db = drizzle(client); 48 + 49 + // Create repositories 50 + queryRepository = new DrizzleCollectionQueryRepository(db); 51 + collectionRepository = new DrizzleCollectionRepository(db); 52 + cardRepository = new DrizzleCardRepository(db); 53 + 54 + // Create schema using helper function 55 + await createTestSchema(db); 56 + 57 + // Create test data 58 + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 59 + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 60 + }, 60000); // Increase timeout for container startup 61 + 62 + // Cleanup after all tests 63 + afterAll(async () => { 64 + // Stop container 65 + await container.stop(); 66 + }); 67 + 68 + // Clear data between tests 69 + beforeEach(async () => { 70 + await db.delete(collectionCards); 71 + await db.delete(collectionCollaborators); 72 + await db.delete(collections); 73 + await db.delete(libraryMemberships); 74 + await db.delete(cards); 75 + await db.delete(publishedRecords); 76 + }); 77 + 78 + describe('URL card in collections', () => { 79 + it('should return collections when user has URL card in multiple collections', async () => { 80 + const testUrl = 'https://example.com/test-article'; 81 + 82 + // Create a URL card 83 + const urlMetadata = UrlMetadata.create({ 84 + url: testUrl, 85 + title: 'Test Article', 86 + description: 'A test article for testing', 87 + author: 'Test Author', 88 + siteName: 'Example.com', 89 + }).unwrap(); 90 + 91 + const card = CardFactory.create({ 92 + curatorId: curatorId.value, 93 + cardInput: { 94 + type: CardTypeEnum.URL, 95 + url: testUrl, 96 + metadata: urlMetadata, 97 + }, 98 + }).unwrap(); 99 + 100 + // Add card to library 101 + const addToLibResult = card.addToLibrary(curatorId); 102 + expect(addToLibResult.isOk()).toBe(true); 103 + 104 + await cardRepository.save(card); 105 + 106 + // Create first collection 107 + const collection1 = Collection.create( 108 + { 109 + authorId: curatorId, 110 + name: 'Tech Articles', 111 + description: 'Collection of technology articles', 112 + accessType: CollectionAccessType.OPEN, 113 + collaboratorIds: [], 114 + createdAt: new Date(), 115 + updatedAt: new Date(), 116 + }, 117 + new UniqueEntityID(), 118 + ).unwrap(); 119 + 120 + // Create second collection 121 + const collection2 = Collection.create( 122 + { 123 + authorId: curatorId, 124 + name: 'Reading List', 125 + description: 'My personal reading list', 126 + accessType: CollectionAccessType.OPEN, 127 + collaboratorIds: [], 128 + createdAt: new Date(), 129 + updatedAt: new Date(), 130 + }, 131 + new UniqueEntityID(), 132 + ).unwrap(); 133 + 134 + // Add card to both collections 135 + const addToCollection1Result = collection1.addCard( 136 + card.cardId, 137 + curatorId, 138 + ); 139 + expect(addToCollection1Result.isOk()).toBe(true); 140 + 141 + const addToCollection2Result = collection2.addCard( 142 + card.cardId, 143 + curatorId, 144 + ); 145 + expect(addToCollection2Result.isOk()).toBe(true); 146 + 147 + // Mark collections as published 148 + const collection1PublishedRecordId = PublishedRecordId.create({ 149 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', 150 + cid: 'bafyreicollection1cid', 151 + }); 152 + 153 + const collection2PublishedRecordId = PublishedRecordId.create({ 154 + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', 155 + cid: 'bafyreicollection2cid', 156 + }); 157 + 158 + collection1.markAsPublished(collection1PublishedRecordId); 159 + collection2.markAsPublished(collection2PublishedRecordId); 160 + 161 + // Save collections 162 + await collectionRepository.save(collection1); 163 + await collectionRepository.save(collection2); 164 + 165 + // Execute the query 166 + const result = await queryRepository.getCollectionsContainingCardForUser( 167 + card.cardId.getStringValue(), 168 + curatorId.value, 169 + ); 170 + 171 + // Verify the result 172 + expect(result).toHaveLength(2); 173 + 174 + // Sort by name for consistent testing 175 + result.sort((a, b) => a.name.localeCompare(b.name)); 176 + 177 + // Verify collection details 178 + expect(result[0]?.id).toBe(collection2.collectionId.getStringValue()); // Reading List comes first alphabetically 179 + expect(result[0]?.uri).toBe( 180 + 'at://did:plc:testcurator/network.cosmik.collection/collection2', 181 + ); 182 + expect(result[0]?.name).toBe('Reading List'); 183 + expect(result[0]?.description).toBe('My personal reading list'); 184 + 185 + expect(result[1]?.id).toBe(collection1.collectionId.getStringValue()); // Tech Articles comes second 186 + expect(result[1]?.uri).toBe( 187 + 'at://did:plc:testcurator/network.cosmik.collection/collection1', 188 + ); 189 + expect(result[1]?.name).toBe('Tech Articles'); 190 + expect(result[1]?.description).toBe('Collection of technology articles'); 191 + }); 192 + 193 + it('should return empty array when user has URL card but not in any collections', async () => { 194 + const testUrl = 'https://example.com/standalone-article'; 195 + 196 + // Create a URL card 197 + const urlMetadata = UrlMetadata.create({ 198 + url: testUrl, 199 + title: 'Standalone Article', 200 + description: 'A standalone article for testing', 201 + }).unwrap(); 202 + 203 + const card = CardFactory.create({ 204 + curatorId: curatorId.value, 205 + cardInput: { 206 + type: CardTypeEnum.URL, 207 + url: testUrl, 208 + metadata: urlMetadata, 209 + }, 210 + }).unwrap(); 211 + 212 + // Add card to library 213 + const addToLibResult = card.addToLibrary(curatorId); 214 + expect(addToLibResult.isOk()).toBe(true); 215 + 216 + await cardRepository.save(card); 217 + 218 + // Execute the query 219 + const result = await queryRepository.getCollectionsContainingCardForUser( 220 + card.cardId.getStringValue(), 221 + curatorId.value, 222 + ); 223 + 224 + // Verify the result 225 + expect(result).toHaveLength(0); 226 + }); 227 + 228 + it('should return empty array when card does not exist', async () => { 229 + const nonExistentCardId = new UniqueEntityID().toString(); 230 + 231 + // Execute the query 232 + const result = await queryRepository.getCollectionsContainingCardForUser( 233 + nonExistentCardId, 234 + curatorId.value, 235 + ); 236 + 237 + // Verify the result 238 + expect(result).toHaveLength(0); 239 + }); 240 + 241 + it('should not return collections from other users even if they have the same card', async () => { 242 + const testUrl = 'https://example.com/shared-article'; 243 + 244 + // Create URL card for first user 245 + const urlMetadata1 = UrlMetadata.create({ 246 + url: testUrl, 247 + title: 'Shared Article', 248 + description: 'An article shared between users', 249 + }).unwrap(); 250 + 251 + const card1 = CardFactory.create({ 252 + curatorId: curatorId.value, 253 + cardInput: { 254 + type: CardTypeEnum.URL, 255 + url: testUrl, 256 + metadata: urlMetadata1, 257 + }, 258 + }).unwrap(); 259 + 260 + const addToLibResult1 = card1.addToLibrary(curatorId); 261 + expect(addToLibResult1.isOk()).toBe(true); 262 + 263 + await cardRepository.save(card1); 264 + 265 + // Create URL card for second user (different card, same URL) 266 + const urlMetadata2 = UrlMetadata.create({ 267 + url: testUrl, 268 + title: 'Shared Article', 269 + description: 'An article shared between users', 270 + }).unwrap(); 271 + 272 + const card2 = CardFactory.create({ 273 + curatorId: otherCuratorId.value, 274 + cardInput: { 275 + type: CardTypeEnum.URL, 276 + url: testUrl, 277 + metadata: urlMetadata2, 278 + }, 279 + }).unwrap(); 280 + 281 + const addToLibResult2 = card2.addToLibrary(otherCuratorId); 282 + expect(addToLibResult2.isOk()).toBe(true); 283 + 284 + await cardRepository.save(card2); 285 + 286 + // Create collection for second user and add their card 287 + const otherUserCollection = Collection.create( 288 + { 289 + authorId: otherCuratorId, 290 + name: 'Other User Collection', 291 + accessType: CollectionAccessType.OPEN, 292 + collaboratorIds: [], 293 + createdAt: new Date(), 294 + updatedAt: new Date(), 295 + }, 296 + new UniqueEntityID(), 297 + ).unwrap(); 298 + 299 + const addToOtherCollectionResult = otherUserCollection.addCard( 300 + card2.cardId, 301 + otherCuratorId, 302 + ); 303 + expect(addToOtherCollectionResult.isOk()).toBe(true); 304 + 305 + await collectionRepository.save(otherUserCollection); 306 + 307 + // Execute the query for first user's card 308 + const result = await queryRepository.getCollectionsContainingCardForUser( 309 + card1.cardId.getStringValue(), 310 + curatorId.value, 311 + ); 312 + 313 + // Verify the result - should be empty since first user's card is not in any collections 314 + expect(result).toHaveLength(0); 315 + }); 316 + 317 + it('should only return collections owned by the requesting user', async () => { 318 + const testUrl = 'https://example.com/multi-user-article'; 319 + 320 + // Create URL card for the user 321 + const urlMetadata = UrlMetadata.create({ 322 + url: testUrl, 323 + title: 'Multi User Article', 324 + description: 'An article for multi-user testing', 325 + }).unwrap(); 326 + 327 + const card = CardFactory.create({ 328 + curatorId: curatorId.value, 329 + cardInput: { 330 + type: CardTypeEnum.URL, 331 + url: testUrl, 332 + metadata: urlMetadata, 333 + }, 334 + }).unwrap(); 335 + 336 + const addToLibResult = card.addToLibrary(curatorId); 337 + expect(addToLibResult.isOk()).toBe(true); 338 + 339 + await cardRepository.save(card); 340 + 341 + // Create user's own collection 342 + const userCollection = Collection.create( 343 + { 344 + authorId: curatorId, 345 + name: 'My Collection', 346 + accessType: CollectionAccessType.OPEN, 347 + collaboratorIds: [], 348 + createdAt: new Date(), 349 + updatedAt: new Date(), 350 + }, 351 + new UniqueEntityID(), 352 + ).unwrap(); 353 + 354 + const addToUserCollectionResult = userCollection.addCard( 355 + card.cardId, 356 + curatorId, 357 + ); 358 + expect(addToUserCollectionResult.isOk()).toBe(true); 359 + 360 + await collectionRepository.save(userCollection); 361 + 362 + // Create another user's collection (this should not appear in results) 363 + const otherUserCollection = Collection.create( 364 + { 365 + authorId: otherCuratorId, 366 + name: 'Other User Collection', 367 + accessType: CollectionAccessType.OPEN, 368 + collaboratorIds: [], 369 + createdAt: new Date(), 370 + updatedAt: new Date(), 371 + }, 372 + new UniqueEntityID(), 373 + ).unwrap(); 374 + 375 + // Note: We don't add the card to the other user's collection since they can't add 376 + // another user's card to their collection in this domain model 377 + 378 + await collectionRepository.save(otherUserCollection); 379 + 380 + // Execute the query 381 + const result = await queryRepository.getCollectionsContainingCardForUser( 382 + card.cardId.getStringValue(), 383 + curatorId.value, 384 + ); 385 + 386 + // Verify the result - should only see user's own collection 387 + expect(result).toHaveLength(1); 388 + expect(result[0]?.name).toBe('My Collection'); 389 + expect(result[0]?.id).toBe(userCollection.collectionId.getStringValue()); 390 + }); 391 + }); 392 + 393 + describe('Note cards in collections', () => { 394 + it('should return collections containing note cards', async () => { 395 + // Create a note card 396 + const card = CardFactory.create({ 397 + curatorId: curatorId.value, 398 + cardInput: { 399 + type: CardTypeEnum.NOTE, 400 + text: 'This is a test note', 401 + }, 402 + }).unwrap(); 403 + 404 + // Add card to library 405 + const addToLibResult = card.addToLibrary(curatorId); 406 + expect(addToLibResult.isOk()).toBe(true); 407 + 408 + await cardRepository.save(card); 409 + 410 + // Create collection 411 + const collection = Collection.create( 412 + { 413 + authorId: curatorId, 414 + name: 'My Notes', 415 + description: 'Collection of my personal notes', 416 + accessType: CollectionAccessType.OPEN, 417 + collaboratorIds: [], 418 + createdAt: new Date(), 419 + updatedAt: new Date(), 420 + }, 421 + new UniqueEntityID(), 422 + ).unwrap(); 423 + 424 + // Add card to collection 425 + const addToCollectionResult = collection.addCard(card.cardId, curatorId); 426 + expect(addToCollectionResult.isOk()).toBe(true); 427 + 428 + await collectionRepository.save(collection); 429 + 430 + // Execute the query 431 + const result = await queryRepository.getCollectionsContainingCardForUser( 432 + card.cardId.getStringValue(), 433 + curatorId.value, 434 + ); 435 + 436 + // Verify the result 437 + expect(result).toHaveLength(1); 438 + expect(result[0]?.id).toBe(collection.collectionId.getStringValue()); 439 + expect(result[0]?.name).toBe('My Notes'); 440 + expect(result[0]?.description).toBe('Collection of my personal notes'); 441 + expect(result[0]?.uri).toBeUndefined(); // Not published 442 + }); 443 + 444 + it('should handle collections with and without descriptions', async () => { 445 + // Create a note card 446 + const card = CardFactory.create({ 447 + curatorId: curatorId.value, 448 + cardInput: { 449 + type: CardTypeEnum.NOTE, 450 + text: 'Test note for collections', 451 + }, 452 + }).unwrap(); 453 + 454 + await cardRepository.save(card); 455 + 456 + // Create collection with description 457 + const collectionWithDesc = Collection.create( 458 + { 459 + authorId: curatorId, 460 + name: 'Collection With Description', 461 + description: 'This collection has a description', 462 + accessType: CollectionAccessType.OPEN, 463 + collaboratorIds: [], 464 + createdAt: new Date(), 465 + updatedAt: new Date(), 466 + }, 467 + new UniqueEntityID(), 468 + ).unwrap(); 469 + 470 + // Create collection without description 471 + const collectionWithoutDesc = Collection.create( 472 + { 473 + authorId: curatorId, 474 + name: 'Collection Without Description', 475 + // No description provided 476 + accessType: CollectionAccessType.OPEN, 477 + collaboratorIds: [], 478 + createdAt: new Date(), 479 + updatedAt: new Date(), 480 + }, 481 + new UniqueEntityID(), 482 + ).unwrap(); 483 + 484 + // Add card to both collections 485 + collectionWithDesc.addCard(card.cardId, curatorId); 486 + collectionWithoutDesc.addCard(card.cardId, curatorId); 487 + 488 + await collectionRepository.save(collectionWithDesc); 489 + await collectionRepository.save(collectionWithoutDesc); 490 + 491 + // Execute the query 492 + const result = await queryRepository.getCollectionsContainingCardForUser( 493 + card.cardId.getStringValue(), 494 + curatorId.value, 495 + ); 496 + 497 + // Verify the result 498 + expect(result).toHaveLength(2); 499 + 500 + // Sort by name for consistent testing 501 + result.sort((a, b) => a.name.localeCompare(b.name)); 502 + 503 + expect(result[0]?.name).toBe('Collection With Description'); 504 + expect(result[0]?.description).toBe('This collection has a description'); 505 + 506 + expect(result[1]?.name).toBe('Collection Without Description'); 507 + expect(result[1]?.description).toBeUndefined(); 508 + }); 509 + }); 510 + 511 + describe('Published and unpublished collections', () => { 512 + it('should return URI for published collections and undefined for unpublished', async () => { 513 + // Create a card 514 + const card = CardFactory.create({ 515 + curatorId: curatorId.value, 516 + cardInput: { 517 + type: CardTypeEnum.NOTE, 518 + text: 'Card for published/unpublished test', 519 + }, 520 + }).unwrap(); 521 + 522 + await cardRepository.save(card); 523 + 524 + // Create published collection 525 + const publishedCollection = Collection.create( 526 + { 527 + authorId: curatorId, 528 + name: 'Published Collection', 529 + description: 'This collection is published', 530 + accessType: CollectionAccessType.OPEN, 531 + collaboratorIds: [], 532 + createdAt: new Date(), 533 + updatedAt: new Date(), 534 + }, 535 + new UniqueEntityID(), 536 + ).unwrap(); 537 + 538 + // Create unpublished collection 539 + const unpublishedCollection = Collection.create( 540 + { 541 + authorId: curatorId, 542 + name: 'Unpublished Collection', 543 + description: 'This collection is not published', 544 + accessType: CollectionAccessType.OPEN, 545 + collaboratorIds: [], 546 + createdAt: new Date(), 547 + updatedAt: new Date(), 548 + }, 549 + new UniqueEntityID(), 550 + ).unwrap(); 551 + 552 + // Mark published collection as published 553 + const publishedRecordId = PublishedRecordId.create({ 554 + uri: 'at://did:plc:testcurator/network.cosmik.collection/published123', 555 + cid: 'bafyreipublishedcid', 556 + }); 557 + 558 + publishedCollection.markAsPublished(publishedRecordId); 559 + 560 + // Add card to both collections 561 + publishedCollection.addCard(card.cardId, curatorId); 562 + unpublishedCollection.addCard(card.cardId, curatorId); 563 + 564 + await collectionRepository.save(publishedCollection); 565 + await collectionRepository.save(unpublishedCollection); 566 + 567 + // Execute the query 568 + const result = await queryRepository.getCollectionsContainingCardForUser( 569 + card.cardId.getStringValue(), 570 + curatorId.value, 571 + ); 572 + 573 + // Verify the result 574 + expect(result).toHaveLength(2); 575 + 576 + // Find collections by name 577 + const publishedResult = result.find( 578 + (c) => c.name === 'Published Collection', 579 + ); 580 + const unpublishedResult = result.find( 581 + (c) => c.name === 'Unpublished Collection', 582 + ); 583 + 584 + expect(publishedResult?.uri).toBe( 585 + 'at://did:plc:testcurator/network.cosmik.collection/published123', 586 + ); 587 + expect(unpublishedResult?.uri).toBeUndefined(); 588 + }); 589 + }); 590 + 591 + describe('Sorting and ordering', () => { 592 + it('should return collections sorted by name in ascending order', async () => { 593 + // Create a card 594 + const card = CardFactory.create({ 595 + curatorId: curatorId.value, 596 + cardInput: { 597 + type: CardTypeEnum.NOTE, 598 + text: 'Card for sorting test', 599 + }, 600 + }).unwrap(); 601 + 602 + await cardRepository.save(card); 603 + 604 + // Create collections with names that will test alphabetical sorting 605 + const collectionNames = [ 606 + 'Zebra Collection', 607 + 'Alpha Collection', 608 + 'Beta Collection', 609 + ]; 610 + 611 + for (const name of collectionNames) { 612 + const collection = Collection.create( 613 + { 614 + authorId: curatorId, 615 + name, 616 + accessType: CollectionAccessType.OPEN, 617 + collaboratorIds: [], 618 + createdAt: new Date(), 619 + updatedAt: new Date(), 620 + }, 621 + new UniqueEntityID(), 622 + ).unwrap(); 623 + 624 + collection.addCard(card.cardId, curatorId); 625 + await collectionRepository.save(collection); 626 + } 627 + 628 + // Execute the query 629 + const result = await queryRepository.getCollectionsContainingCardForUser( 630 + card.cardId.getStringValue(), 631 + curatorId.value, 632 + ); 633 + 634 + // Verify the result is sorted by name 635 + expect(result).toHaveLength(3); 636 + expect(result[0]?.name).toBe('Alpha Collection'); 637 + expect(result[1]?.name).toBe('Beta Collection'); 638 + expect(result[2]?.name).toBe('Zebra Collection'); 639 + }); 640 + }); 641 + 642 + describe('Edge cases', () => { 643 + it('should handle non-existent curator gracefully', async () => { 644 + const card = CardFactory.create({ 645 + curatorId: curatorId.value, 646 + cardInput: { 647 + type: CardTypeEnum.NOTE, 648 + text: 'Test card', 649 + }, 650 + }).unwrap(); 651 + 652 + await cardRepository.save(card); 653 + 654 + // Execute the query with non-existent curator 655 + const result = await queryRepository.getCollectionsContainingCardForUser( 656 + card.cardId.getStringValue(), 657 + 'did:plc:nonexistent', 658 + ); 659 + 660 + // Verify the result 661 + expect(result).toHaveLength(0); 662 + }); 663 + 664 + it('should handle empty curator ID gracefully', async () => { 665 + const card = CardFactory.create({ 666 + curatorId: curatorId.value, 667 + cardInput: { 668 + type: CardTypeEnum.NOTE, 669 + text: 'Test card', 670 + }, 671 + }).unwrap(); 672 + 673 + await cardRepository.save(card); 674 + 675 + // Execute the query with empty curator ID 676 + const result = await queryRepository.getCollectionsContainingCardForUser( 677 + card.cardId.getStringValue(), 678 + '', 679 + ); 680 + 681 + // Verify the result 682 + expect(result).toHaveLength(0); 683 + }); 684 + 685 + it('should handle collections with null published records', async () => { 686 + // Create a card 687 + const card = CardFactory.create({ 688 + curatorId: curatorId.value, 689 + cardInput: { 690 + type: CardTypeEnum.NOTE, 691 + text: 'Card for null published record test', 692 + }, 693 + }).unwrap(); 694 + 695 + await cardRepository.save(card); 696 + 697 + // Create collection without published record 698 + const collection = Collection.create( 699 + { 700 + authorId: curatorId, 701 + name: 'Collection Without Published Record', 702 + accessType: CollectionAccessType.OPEN, 703 + collaboratorIds: [], 704 + createdAt: new Date(), 705 + updatedAt: new Date(), 706 + }, 707 + new UniqueEntityID(), 708 + ).unwrap(); 709 + 710 + collection.addCard(card.cardId, curatorId); 711 + await collectionRepository.save(collection); 712 + 713 + // Execute the query 714 + const result = await queryRepository.getCollectionsContainingCardForUser( 715 + card.cardId.getStringValue(), 716 + curatorId.value, 717 + ); 718 + 719 + // Verify the result 720 + expect(result).toHaveLength(1); 721 + expect(result[0]?.uri).toBeUndefined(); 722 + expect(result[0]?.name).toBe('Collection Without Published Record'); 723 + }); 724 + }); 725 + 726 + describe('Multiple card types', () => { 727 + it('should work with different card types in the same collection', async () => { 728 + // Create different types of cards 729 + const urlMetadata = UrlMetadata.create({ 730 + url: 'https://example.com/test', 731 + title: 'Test URL', 732 + description: 'A test URL for mixed content', 733 + }).unwrap(); 734 + 735 + const urlCard = CardFactory.create({ 736 + curatorId: curatorId.value, 737 + cardInput: { 738 + type: CardTypeEnum.URL, 739 + url: 'https://example.com/test', 740 + metadata: urlMetadata, 741 + }, 742 + }).unwrap(); 743 + 744 + const noteCard = CardFactory.create({ 745 + curatorId: curatorId.value, 746 + cardInput: { 747 + type: CardTypeEnum.NOTE, 748 + text: 'Test note', 749 + }, 750 + }).unwrap(); 751 + 752 + await cardRepository.save(urlCard); 753 + await cardRepository.save(noteCard); 754 + 755 + // Create collection and add all cards 756 + const collection = Collection.create( 757 + { 758 + authorId: curatorId, 759 + name: 'Mixed Content Collection', 760 + description: 'Collection with different card types', 761 + accessType: CollectionAccessType.OPEN, 762 + collaboratorIds: [], 763 + createdAt: new Date(), 764 + updatedAt: new Date(), 765 + }, 766 + new UniqueEntityID(), 767 + ).unwrap(); 768 + 769 + collection.addCard(urlCard.cardId, curatorId); 770 + collection.addCard(noteCard.cardId, curatorId); 771 + 772 + await collectionRepository.save(collection); 773 + 774 + // Test each card type 775 + const urlResult = 776 + await queryRepository.getCollectionsContainingCardForUser( 777 + urlCard.cardId.getStringValue(), 778 + curatorId.value, 779 + ); 780 + 781 + const noteResult = 782 + await queryRepository.getCollectionsContainingCardForUser( 783 + noteCard.cardId.getStringValue(), 784 + curatorId.value, 785 + ); 786 + 787 + // Verify all return the same collection 788 + expect(urlResult).toHaveLength(1); 789 + expect(noteResult).toHaveLength(1); 790 + 791 + expect(urlResult[0]?.name).toBe('Mixed Content Collection'); 792 + expect(noteResult[0]?.name).toBe('Mixed Content Collection'); 793 + 794 + expect(urlResult[0]?.id).toBe(collection.collectionId.getStringValue()); 795 + expect(noteResult[0]?.id).toBe(collection.collectionId.getStringValue()); 796 + }); 797 + }); 798 + });
+26 -1
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 19 19 // Cards table (references published_records and self-references) 20 20 sql`CREATE TABLE IF NOT EXISTS cards ( 21 21 id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 22 + author_id TEXT NOT NULL, 22 23 type TEXT NOT NULL, 23 24 content_data JSONB NOT NULL, 24 25 url TEXT, 25 26 parent_card_id UUID REFERENCES cards(id), 26 - original_published_record_id UUID REFERENCES published_records(id), 27 + published_record_id UUID REFERENCES published_records(id), 27 28 library_count INTEGER NOT NULL DEFAULT 0, 28 29 created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 29 30 updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ··· 102 103 // Index for efficient AT URI lookups 103 104 await db.execute(sql` 104 105 CREATE INDEX IF NOT EXISTS published_records_uri_idx ON published_records(uri); 106 + `); 107 + 108 + // Cards table indexes 109 + await db.execute(sql` 110 + CREATE INDEX IF NOT EXISTS cards_author_url_idx ON cards(author_id, url); 111 + `); 112 + await db.execute(sql` 113 + CREATE INDEX IF NOT EXISTS cards_author_id_idx ON cards(author_id); 114 + `); 115 + 116 + // Collections table indexes 117 + await db.execute(sql` 118 + CREATE INDEX IF NOT EXISTS collections_author_id_idx ON collections(author_id); 119 + `); 120 + await db.execute(sql` 121 + CREATE INDEX IF NOT EXISTS collections_author_updated_at_idx ON collections(author_id, updated_at); 122 + `); 123 + 124 + // Collection cards table indexes 125 + await db.execute(sql` 126 + CREATE INDEX IF NOT EXISTS collection_cards_card_id_idx ON collection_cards(card_id); 127 + `); 128 + await db.execute(sql` 129 + CREATE INDEX IF NOT EXISTS collection_cards_collection_id_idx ON collection_cards(collection_id); 105 130 `); 106 131 }
+2 -1
src/modules/cards/tests/utils/FakeCardPublisher.ts
··· 19 19 async publishCardToLibrary( 20 20 card: Card, 21 21 curatorId: CuratorId, 22 + parentCard?: Card, 22 23 ): Promise<Result<PublishedRecordId, UseCaseError>> { 23 24 if (this.shouldFail) { 24 25 return err( ··· 28 29 29 30 const cardId = card.cardId.getStringValue(); 30 31 // Simulate generating an AT URI based on curator DID and collection/rkey 31 - const fakeUri = `at://${curatorId.value}/network.cosmik.card/${cardId}`; 32 + const fakeUri = `at://${curatorId.value}/network.cosmik.dev.card/${cardId}`; 32 33 const fakeCid = `fake-cid-${cardId}`; 33 34 const publishedRecordId = PublishedRecordId.create({ 34 35 uri: fakeUri,
+1 -1
src/modules/cards/tests/utils/FakeCollectionPublisher.ts
··· 33 33 const fakeDid = process.env.BSKY_DID || 'did:plc:rlknsba2qldjkicxsmni3vyn'; 34 34 35 35 // Simulate publishing the collection record itself 36 - const fakeCollectionUri = `at://${fakeDid}/network.cosmik.collection/${collectionId}`; 36 + const fakeCollectionUri = `at://${fakeDid}/network.cosmik.dev.collection/${collectionId}`; 37 37 const fakeCollectionCid = `fake-collection-cid-${collectionId}`; 38 38 39 39 const collectionRecord = PublishedRecordId.create({
+11 -4
src/modules/cards/tests/utils/InMemoryCardRepository.ts
··· 2 2 import { ICardRepository } from '../../domain/ICardRepository'; 3 3 import { Card } from '../../domain/Card'; 4 4 import { CardId } from '../../domain/value-objects/CardId'; 5 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 5 6 import { URL } from '../../domain/value-objects/URL'; 6 7 7 8 export class InMemoryCardRepository implements ICardRepository { ··· 13 14 // Simple clone - in a real implementation you'd want proper deep cloning 14 15 const cardResult = Card.create( 15 16 { 17 + curatorId: card.props.curatorId, 16 18 type: card.type, 17 19 content: card.content, 18 20 parentCardId: card.parentCardId, 19 21 url: card.url, 20 - originalPublishedRecordId: card.originalPublishedRecordId, 22 + publishedRecordId: card.publishedRecordId, 21 23 libraryMemberships: card.libraryMemberships, 24 + libraryCount: card.libraryCount, 22 25 createdAt: card.createdAt, 23 26 updatedAt: card.updatedAt, 24 27 }, ··· 44 47 } 45 48 } 46 49 47 - async findUrlCardByUrl(url: URL): Promise<Result<Card | null>> { 50 + async findUsersUrlCardByUrl( 51 + url: URL, 52 + curatorId: CuratorId, 53 + ): Promise<Result<Card | null>> { 48 54 try { 49 55 const card = Array.from(this.cards.values()).find( 50 56 (card) => 51 - card.content.type === 'URL' && 52 - card.content.urlContent?.url.value === url.value, 57 + card.type.value === 'URL' && 58 + card.url?.value === url.value && 59 + card.props.curatorId.equals(curatorId), 53 60 ); 54 61 return ok(card ? this.clone(card) : null); 55 62 } catch (error) {
+40 -1
src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts
··· 1 - import { Result, ok, err } from '../../../../shared/core/Result'; 2 1 import { 3 2 ICollectionQueryRepository, 4 3 CollectionQueryOptions, 5 4 CollectionQueryResultDTO, 5 + CollectionContainingCardDTO, 6 6 PaginatedQueryResult, 7 7 CollectionSortField, 8 8 SortOrder, ··· 114 114 }); 115 115 116 116 return sorted; 117 + } 118 + 119 + async getCollectionsContainingCardForUser( 120 + cardId: string, 121 + curatorId: string, 122 + ): Promise<CollectionContainingCardDTO[]> { 123 + try { 124 + // Get all collections and filter by creator 125 + const allCollections = this.collectionRepository.getAllCollections(); 126 + const creatorCollections = allCollections.filter( 127 + (collection) => collection.authorId.value === curatorId, 128 + ); 129 + 130 + // Filter collections that contain the specified card 131 + const collectionsWithCard = creatorCollections.filter((collection) => 132 + collection.cardLinks.some( 133 + (link) => link.cardId.getStringValue() === cardId, 134 + ), 135 + ); 136 + 137 + // Transform to DTOs 138 + const result: CollectionContainingCardDTO[] = collectionsWithCard.map( 139 + (collection) => { 140 + const collectionPublishedRecordId = collection.publishedRecordId; 141 + return { 142 + id: collection.collectionId.getStringValue(), 143 + uri: collectionPublishedRecordId?.uri, 144 + name: collection.name.value, 145 + description: collection.description?.value, 146 + }; 147 + }, 148 + ); 149 + 150 + return result; 151 + } catch (error) { 152 + throw new Error( 153 + `Failed to get collections containing card: ${error instanceof Error ? error.message : String(error)}`, 154 + ); 155 + } 117 156 } 118 157 119 158 clear(): void {
+10 -16
src/modules/cards/tests/utils/builders/CardBuilder.ts
··· 15 15 private _content?: CardContent; 16 16 private _url?: URL; 17 17 private _parentCardId?: CardId; 18 - private _originalPublishedRecordId?: PublishedRecordId; 18 + private _publishedRecordId?: PublishedRecordId; 19 19 private _createdAt?: Date; 20 20 private _updatedAt?: Date; 21 21 ··· 43 43 } else if (type === CardTypeEnum.NOTE) { 44 44 const curatorIdResult = CuratorId.create(this._curatorId); 45 45 if (curatorIdResult.isOk()) { 46 - const contentResult = CardContent.createNoteContent( 47 - 'Default note text', 48 - curatorIdResult.value, 49 - ); 46 + const contentResult = 47 + CardContent.createNoteContent('Default note text'); 50 48 if (contentResult.isOk()) { 51 49 this._content = contentResult.value; 52 50 } ··· 66 64 return this; 67 65 } 68 66 69 - withOriginalPublishedRecordId(originalPublishedRecordId: { 67 + withPublishedRecordId(originalPublishedRecordId: { 70 68 uri: string; 71 69 cid: string; 72 70 }): CardBuilder { 73 - this._originalPublishedRecordId = PublishedRecordId.create( 71 + this._publishedRecordId = PublishedRecordId.create( 74 72 originalPublishedRecordId, 75 73 ); 76 74 return this; ··· 102 100 103 101 withNoteCard(text: string): CardBuilder { 104 102 this._type = CardTypeEnum.NOTE; 105 - const contentResult = CardContent.createNoteContent( 106 - text, 107 - CuratorId.create(this._curatorId).unwrap(), 108 - ); 103 + const contentResult = CardContent.createNoteContent(text); 109 104 if (contentResult.isErr()) { 110 105 throw new Error( 111 106 `Failed to create note content: ${contentResult.error.message}`, ··· 138 133 } 139 134 this._content = contentResult.value; 140 135 } else if (this._type === CardTypeEnum.NOTE) { 141 - const contentResult = CardContent.createNoteContent( 142 - 'Default note text', 143 - curatorIdResult.value, 144 - ); 136 + const contentResult = 137 + CardContent.createNoteContent('Default note text'); 145 138 if (contentResult.isErr()) { 146 139 return new Error( 147 140 `Failed to create note content: ${contentResult.error.message}`, ··· 167 160 168 161 const cardResult = Card.create( 169 162 { 163 + curatorId: curatorIdResult.value, 170 164 type: cardTypeResult.value, 171 165 content: this._content, 172 166 url: this._url, 173 167 parentCardId: this._parentCardId, 174 - originalPublishedRecordId: this._originalPublishedRecordId, 168 + publishedRecordId: this._publishedRecordId, 175 169 createdAt: this._createdAt, 176 170 updatedAt: this._updatedAt, 177 171 },
+1 -1
src/modules/cards/tests/utils/builders/CollectionBuilder.ts
··· 57 57 withPublished(published: boolean): CollectionBuilder { 58 58 if (published) { 59 59 // Create a fake published record ID when marking as published 60 - const fakeUri = `at://fake-did/network.cosmik.collection/${this._id?.toString() || 'fake-id'}`; 60 + const fakeUri = `at://fake-did/network.cosmik.dev.collection/${this._id?.toString() || 'fake-id'}`; 61 61 const fakeCid = `fake-collection-cid-${this._id?.toString() || 'fake-id'}`; 62 62 this._publishedRecordId = PublishedRecordId.create({ 63 63 uri: fakeUri,
+23
src/shared/infrastructure/config/EnvironmentConfigService.ts
··· 11 11 atproto: { 12 12 serviceEndpoint: string; 13 13 baseUrl: string; 14 + collections: { 15 + card: string; 16 + collection: string; 17 + collectionLink: string; 18 + }; 14 19 }; 15 20 server: { 16 21 port: number; ··· 63 68 serviceEndpoint: 64 69 process.env.ATPROTO_SERVICE_ENDPOINT || 'https://bsky.social', 65 70 baseUrl: process.env.BASE_URL || 'http://127.0.0.1:3000', 71 + collections: { 72 + card: 73 + environment === 'prod' 74 + ? 'network.cosmik.card' 75 + : 'network.cosmik.dev.card', 76 + collection: 77 + environment === 'prod' 78 + ? 'network.cosmik.collection' 79 + : 'network.cosmik.dev.collection', 80 + collectionLink: 81 + environment === 'prod' 82 + ? 'network.cosmik.collectionLink' 83 + : 'network.cosmik.dev.collectionLink', 84 + }, 66 85 }, 67 86 server: { 68 87 port: parseInt(process.env.PORT || '3000'), ··· 122 141 123 142 public getAtProtoConfig() { 124 143 return this.config.atproto; 144 + } 145 + 146 + public getAtProtoCollections() { 147 + return this.config.atproto.collections; 125 148 } 126 149 127 150 public getServerConfig() {
+5
src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql
··· 1 + ALTER TABLE "cards" RENAME COLUMN "original_published_record_id" TO "published_record_id";--> statement-breakpoint 2 + ALTER TABLE "cards" DROP CONSTRAINT "cards_original_published_record_id_published_records_id_fk"; 3 + --> statement-breakpoint 4 + ALTER TABLE "cards" ADD COLUMN "author_id" text NOT NULL;--> statement-breakpoint 5 + ALTER TABLE "cards" ADD CONSTRAINT "cards_published_record_id_published_records_id_fk" FOREIGN KEY ("published_record_id") REFERENCES "public"."published_records"("id") ON DELETE no action ON UPDATE no action;
+10
src/shared/infrastructure/database/migrations/0005_truncate-tables.sql
··· 1 + -- Custom SQL migration file, put your code below! -- 2 + -- Truncate all tables to wipe data but keep table structure 3 + -- CASCADE ensures foreign key constraints don't prevent truncation 4 + TRUNCATE TABLE "collection_cards", 5 + "collection_collaborators", 6 + "library_memberships", 7 + "collections", 8 + "cards", 9 + "published_records", 10 + "feed_activities" CASCADE;
+6
src/shared/infrastructure/database/migrations/0006_lovely_randall.sql
··· 1 + CREATE INDEX "cards_author_url_idx" ON "cards" USING btree ("author_id","url");--> statement-breakpoint 2 + CREATE INDEX "cards_author_id_idx" ON "cards" USING btree ("author_id");--> statement-breakpoint 3 + CREATE INDEX "collection_cards_card_id_idx" ON "collection_cards" USING btree ("card_id");--> statement-breakpoint 4 + CREATE INDEX "collection_cards_collection_id_idx" ON "collection_cards" USING btree ("collection_id");--> statement-breakpoint 5 + CREATE INDEX "collections_author_id_idx" ON "collections" USING btree ("author_id");--> statement-breakpoint 6 + CREATE INDEX "collections_author_updated_at_idx" ON "collections" USING btree ("author_id","updated_at");
+724
src/shared/infrastructure/database/migrations/meta/0004_snapshot.json
··· 1 + { 2 + "id": "1e15b3e8-04ba-4e0a-965e-07142597ddc4", 3 + "prevId": "69abc7ad-c872-43e9-8e83-4d9362cf62c7", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "parent_card_id": { 87 + "name": "parent_card_id", 88 + "type": "uuid", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "published_record_id": { 93 + "name": "published_record_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "library_count": { 99 + "name": "library_count", 100 + "type": "integer", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "default": 0 104 + }, 105 + "created_at": { 106 + "name": "created_at", 107 + "type": "timestamp", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "default": "now()" 111 + }, 112 + "updated_at": { 113 + "name": "updated_at", 114 + "type": "timestamp", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "default": "now()" 118 + } 119 + }, 120 + "indexes": {}, 121 + "foreignKeys": { 122 + "cards_parent_card_id_cards_id_fk": { 123 + "name": "cards_parent_card_id_cards_id_fk", 124 + "tableFrom": "cards", 125 + "tableTo": "cards", 126 + "columnsFrom": ["parent_card_id"], 127 + "columnsTo": ["id"], 128 + "onDelete": "no action", 129 + "onUpdate": "no action" 130 + }, 131 + "cards_published_record_id_published_records_id_fk": { 132 + "name": "cards_published_record_id_published_records_id_fk", 133 + "tableFrom": "cards", 134 + "tableTo": "published_records", 135 + "columnsFrom": ["published_record_id"], 136 + "columnsTo": ["id"], 137 + "onDelete": "no action", 138 + "onUpdate": "no action" 139 + } 140 + }, 141 + "compositePrimaryKeys": {}, 142 + "uniqueConstraints": {}, 143 + "policies": {}, 144 + "checkConstraints": {}, 145 + "isRLSEnabled": false 146 + }, 147 + "public.collection_cards": { 148 + "name": "collection_cards", 149 + "schema": "", 150 + "columns": { 151 + "id": { 152 + "name": "id", 153 + "type": "uuid", 154 + "primaryKey": true, 155 + "notNull": true 156 + }, 157 + "collection_id": { 158 + "name": "collection_id", 159 + "type": "uuid", 160 + "primaryKey": false, 161 + "notNull": true 162 + }, 163 + "card_id": { 164 + "name": "card_id", 165 + "type": "uuid", 166 + "primaryKey": false, 167 + "notNull": true 168 + }, 169 + "added_by": { 170 + "name": "added_by", 171 + "type": "text", 172 + "primaryKey": false, 173 + "notNull": true 174 + }, 175 + "added_at": { 176 + "name": "added_at", 177 + "type": "timestamp", 178 + "primaryKey": false, 179 + "notNull": true, 180 + "default": "now()" 181 + }, 182 + "published_record_id": { 183 + "name": "published_record_id", 184 + "type": "uuid", 185 + "primaryKey": false, 186 + "notNull": false 187 + } 188 + }, 189 + "indexes": {}, 190 + "foreignKeys": { 191 + "collection_cards_collection_id_collections_id_fk": { 192 + "name": "collection_cards_collection_id_collections_id_fk", 193 + "tableFrom": "collection_cards", 194 + "tableTo": "collections", 195 + "columnsFrom": ["collection_id"], 196 + "columnsTo": ["id"], 197 + "onDelete": "cascade", 198 + "onUpdate": "no action" 199 + }, 200 + "collection_cards_card_id_cards_id_fk": { 201 + "name": "collection_cards_card_id_cards_id_fk", 202 + "tableFrom": "collection_cards", 203 + "tableTo": "cards", 204 + "columnsFrom": ["card_id"], 205 + "columnsTo": ["id"], 206 + "onDelete": "cascade", 207 + "onUpdate": "no action" 208 + }, 209 + "collection_cards_published_record_id_published_records_id_fk": { 210 + "name": "collection_cards_published_record_id_published_records_id_fk", 211 + "tableFrom": "collection_cards", 212 + "tableTo": "published_records", 213 + "columnsFrom": ["published_record_id"], 214 + "columnsTo": ["id"], 215 + "onDelete": "no action", 216 + "onUpdate": "no action" 217 + } 218 + }, 219 + "compositePrimaryKeys": {}, 220 + "uniqueConstraints": {}, 221 + "policies": {}, 222 + "checkConstraints": {}, 223 + "isRLSEnabled": false 224 + }, 225 + "public.collection_collaborators": { 226 + "name": "collection_collaborators", 227 + "schema": "", 228 + "columns": { 229 + "id": { 230 + "name": "id", 231 + "type": "uuid", 232 + "primaryKey": true, 233 + "notNull": true 234 + }, 235 + "collection_id": { 236 + "name": "collection_id", 237 + "type": "uuid", 238 + "primaryKey": false, 239 + "notNull": true 240 + }, 241 + "collaborator_id": { 242 + "name": "collaborator_id", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": true 246 + } 247 + }, 248 + "indexes": {}, 249 + "foreignKeys": { 250 + "collection_collaborators_collection_id_collections_id_fk": { 251 + "name": "collection_collaborators_collection_id_collections_id_fk", 252 + "tableFrom": "collection_collaborators", 253 + "tableTo": "collections", 254 + "columnsFrom": ["collection_id"], 255 + "columnsTo": ["id"], 256 + "onDelete": "cascade", 257 + "onUpdate": "no action" 258 + } 259 + }, 260 + "compositePrimaryKeys": {}, 261 + "uniqueConstraints": {}, 262 + "policies": {}, 263 + "checkConstraints": {}, 264 + "isRLSEnabled": false 265 + }, 266 + "public.collections": { 267 + "name": "collections", 268 + "schema": "", 269 + "columns": { 270 + "id": { 271 + "name": "id", 272 + "type": "uuid", 273 + "primaryKey": true, 274 + "notNull": true 275 + }, 276 + "author_id": { 277 + "name": "author_id", 278 + "type": "text", 279 + "primaryKey": false, 280 + "notNull": true 281 + }, 282 + "name": { 283 + "name": "name", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true 287 + }, 288 + "description": { 289 + "name": "description", 290 + "type": "text", 291 + "primaryKey": false, 292 + "notNull": false 293 + }, 294 + "access_type": { 295 + "name": "access_type", 296 + "type": "text", 297 + "primaryKey": false, 298 + "notNull": true 299 + }, 300 + "card_count": { 301 + "name": "card_count", 302 + "type": "integer", 303 + "primaryKey": false, 304 + "notNull": true, 305 + "default": 0 306 + }, 307 + "created_at": { 308 + "name": "created_at", 309 + "type": "timestamp", 310 + "primaryKey": false, 311 + "notNull": true, 312 + "default": "now()" 313 + }, 314 + "updated_at": { 315 + "name": "updated_at", 316 + "type": "timestamp", 317 + "primaryKey": false, 318 + "notNull": true, 319 + "default": "now()" 320 + }, 321 + "published_record_id": { 322 + "name": "published_record_id", 323 + "type": "uuid", 324 + "primaryKey": false, 325 + "notNull": false 326 + } 327 + }, 328 + "indexes": {}, 329 + "foreignKeys": { 330 + "collections_published_record_id_published_records_id_fk": { 331 + "name": "collections_published_record_id_published_records_id_fk", 332 + "tableFrom": "collections", 333 + "tableTo": "published_records", 334 + "columnsFrom": ["published_record_id"], 335 + "columnsTo": ["id"], 336 + "onDelete": "no action", 337 + "onUpdate": "no action" 338 + } 339 + }, 340 + "compositePrimaryKeys": {}, 341 + "uniqueConstraints": {}, 342 + "policies": {}, 343 + "checkConstraints": {}, 344 + "isRLSEnabled": false 345 + }, 346 + "public.library_memberships": { 347 + "name": "library_memberships", 348 + "schema": "", 349 + "columns": { 350 + "card_id": { 351 + "name": "card_id", 352 + "type": "uuid", 353 + "primaryKey": false, 354 + "notNull": true 355 + }, 356 + "user_id": { 357 + "name": "user_id", 358 + "type": "text", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "added_at": { 363 + "name": "added_at", 364 + "type": "timestamp", 365 + "primaryKey": false, 366 + "notNull": true, 367 + "default": "now()" 368 + }, 369 + "published_record_id": { 370 + "name": "published_record_id", 371 + "type": "uuid", 372 + "primaryKey": false, 373 + "notNull": false 374 + } 375 + }, 376 + "indexes": { 377 + "idx_user_cards": { 378 + "name": "idx_user_cards", 379 + "columns": [ 380 + { 381 + "expression": "user_id", 382 + "isExpression": false, 383 + "asc": true, 384 + "nulls": "last" 385 + } 386 + ], 387 + "isUnique": false, 388 + "concurrently": false, 389 + "method": "btree", 390 + "with": {} 391 + }, 392 + "idx_card_users": { 393 + "name": "idx_card_users", 394 + "columns": [ 395 + { 396 + "expression": "card_id", 397 + "isExpression": false, 398 + "asc": true, 399 + "nulls": "last" 400 + } 401 + ], 402 + "isUnique": false, 403 + "concurrently": false, 404 + "method": "btree", 405 + "with": {} 406 + } 407 + }, 408 + "foreignKeys": { 409 + "library_memberships_card_id_cards_id_fk": { 410 + "name": "library_memberships_card_id_cards_id_fk", 411 + "tableFrom": "library_memberships", 412 + "tableTo": "cards", 413 + "columnsFrom": ["card_id"], 414 + "columnsTo": ["id"], 415 + "onDelete": "cascade", 416 + "onUpdate": "no action" 417 + }, 418 + "library_memberships_published_record_id_published_records_id_fk": { 419 + "name": "library_memberships_published_record_id_published_records_id_fk", 420 + "tableFrom": "library_memberships", 421 + "tableTo": "published_records", 422 + "columnsFrom": ["published_record_id"], 423 + "columnsTo": ["id"], 424 + "onDelete": "no action", 425 + "onUpdate": "no action" 426 + } 427 + }, 428 + "compositePrimaryKeys": { 429 + "library_memberships_card_id_user_id_pk": { 430 + "name": "library_memberships_card_id_user_id_pk", 431 + "columns": ["card_id", "user_id"] 432 + } 433 + }, 434 + "uniqueConstraints": {}, 435 + "policies": {}, 436 + "checkConstraints": {}, 437 + "isRLSEnabled": false 438 + }, 439 + "public.published_records": { 440 + "name": "published_records", 441 + "schema": "", 442 + "columns": { 443 + "id": { 444 + "name": "id", 445 + "type": "uuid", 446 + "primaryKey": true, 447 + "notNull": true 448 + }, 449 + "uri": { 450 + "name": "uri", 451 + "type": "text", 452 + "primaryKey": false, 453 + "notNull": true 454 + }, 455 + "cid": { 456 + "name": "cid", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "recorded_at": { 462 + "name": "recorded_at", 463 + "type": "timestamp", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "default": "now()" 467 + } 468 + }, 469 + "indexes": { 470 + "uri_cid_unique_idx": { 471 + "name": "uri_cid_unique_idx", 472 + "columns": [ 473 + { 474 + "expression": "uri", 475 + "isExpression": false, 476 + "asc": true, 477 + "nulls": "last" 478 + }, 479 + { 480 + "expression": "cid", 481 + "isExpression": false, 482 + "asc": true, 483 + "nulls": "last" 484 + } 485 + ], 486 + "isUnique": true, 487 + "concurrently": false, 488 + "method": "btree", 489 + "with": {} 490 + }, 491 + "published_records_uri_idx": { 492 + "name": "published_records_uri_idx", 493 + "columns": [ 494 + { 495 + "expression": "uri", 496 + "isExpression": false, 497 + "asc": true, 498 + "nulls": "last" 499 + } 500 + ], 501 + "isUnique": false, 502 + "concurrently": false, 503 + "method": "btree", 504 + "with": {} 505 + } 506 + }, 507 + "foreignKeys": {}, 508 + "compositePrimaryKeys": {}, 509 + "uniqueConstraints": {}, 510 + "policies": {}, 511 + "checkConstraints": {}, 512 + "isRLSEnabled": false 513 + }, 514 + "public.feed_activities": { 515 + "name": "feed_activities", 516 + "schema": "", 517 + "columns": { 518 + "id": { 519 + "name": "id", 520 + "type": "uuid", 521 + "primaryKey": true, 522 + "notNull": true 523 + }, 524 + "actor_id": { 525 + "name": "actor_id", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": true 529 + }, 530 + "type": { 531 + "name": "type", 532 + "type": "text", 533 + "primaryKey": false, 534 + "notNull": true 535 + }, 536 + "metadata": { 537 + "name": "metadata", 538 + "type": "jsonb", 539 + "primaryKey": false, 540 + "notNull": true 541 + }, 542 + "created_at": { 543 + "name": "created_at", 544 + "type": "timestamp", 545 + "primaryKey": false, 546 + "notNull": true, 547 + "default": "now()" 548 + } 549 + }, 550 + "indexes": {}, 551 + "foreignKeys": {}, 552 + "compositePrimaryKeys": {}, 553 + "uniqueConstraints": {}, 554 + "policies": {}, 555 + "checkConstraints": {}, 556 + "isRLSEnabled": false 557 + }, 558 + "public.auth_session": { 559 + "name": "auth_session", 560 + "schema": "", 561 + "columns": { 562 + "key": { 563 + "name": "key", 564 + "type": "text", 565 + "primaryKey": true, 566 + "notNull": true 567 + }, 568 + "session": { 569 + "name": "session", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + } 574 + }, 575 + "indexes": {}, 576 + "foreignKeys": {}, 577 + "compositePrimaryKeys": {}, 578 + "uniqueConstraints": {}, 579 + "policies": {}, 580 + "checkConstraints": {}, 581 + "isRLSEnabled": false 582 + }, 583 + "public.auth_state": { 584 + "name": "auth_state", 585 + "schema": "", 586 + "columns": { 587 + "key": { 588 + "name": "key", 589 + "type": "text", 590 + "primaryKey": true, 591 + "notNull": true 592 + }, 593 + "state": { 594 + "name": "state", 595 + "type": "text", 596 + "primaryKey": false, 597 + "notNull": true 598 + }, 599 + "created_at": { 600 + "name": "created_at", 601 + "type": "timestamp", 602 + "primaryKey": false, 603 + "notNull": false, 604 + "default": "now()" 605 + } 606 + }, 607 + "indexes": {}, 608 + "foreignKeys": {}, 609 + "compositePrimaryKeys": {}, 610 + "uniqueConstraints": {}, 611 + "policies": {}, 612 + "checkConstraints": {}, 613 + "isRLSEnabled": false 614 + }, 615 + "public.auth_refresh_tokens": { 616 + "name": "auth_refresh_tokens", 617 + "schema": "", 618 + "columns": { 619 + "token_id": { 620 + "name": "token_id", 621 + "type": "text", 622 + "primaryKey": true, 623 + "notNull": true 624 + }, 625 + "user_did": { 626 + "name": "user_did", 627 + "type": "text", 628 + "primaryKey": false, 629 + "notNull": true 630 + }, 631 + "refresh_token": { 632 + "name": "refresh_token", 633 + "type": "text", 634 + "primaryKey": false, 635 + "notNull": true 636 + }, 637 + "issued_at": { 638 + "name": "issued_at", 639 + "type": "timestamp", 640 + "primaryKey": false, 641 + "notNull": true 642 + }, 643 + "expires_at": { 644 + "name": "expires_at", 645 + "type": "timestamp", 646 + "primaryKey": false, 647 + "notNull": true 648 + }, 649 + "revoked": { 650 + "name": "revoked", 651 + "type": "boolean", 652 + "primaryKey": false, 653 + "notNull": false, 654 + "default": false 655 + } 656 + }, 657 + "indexes": {}, 658 + "foreignKeys": { 659 + "auth_refresh_tokens_user_did_users_id_fk": { 660 + "name": "auth_refresh_tokens_user_did_users_id_fk", 661 + "tableFrom": "auth_refresh_tokens", 662 + "tableTo": "users", 663 + "columnsFrom": ["user_did"], 664 + "columnsTo": ["id"], 665 + "onDelete": "no action", 666 + "onUpdate": "no action" 667 + } 668 + }, 669 + "compositePrimaryKeys": {}, 670 + "uniqueConstraints": {}, 671 + "policies": {}, 672 + "checkConstraints": {}, 673 + "isRLSEnabled": false 674 + }, 675 + "public.users": { 676 + "name": "users", 677 + "schema": "", 678 + "columns": { 679 + "id": { 680 + "name": "id", 681 + "type": "text", 682 + "primaryKey": true, 683 + "notNull": true 684 + }, 685 + "handle": { 686 + "name": "handle", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": false 690 + }, 691 + "linked_at": { 692 + "name": "linked_at", 693 + "type": "timestamp", 694 + "primaryKey": false, 695 + "notNull": true 696 + }, 697 + "last_login_at": { 698 + "name": "last_login_at", 699 + "type": "timestamp", 700 + "primaryKey": false, 701 + "notNull": true 702 + } 703 + }, 704 + "indexes": {}, 705 + "foreignKeys": {}, 706 + "compositePrimaryKeys": {}, 707 + "uniqueConstraints": {}, 708 + "policies": {}, 709 + "checkConstraints": {}, 710 + "isRLSEnabled": false 711 + } 712 + }, 713 + "enums": {}, 714 + "schemas": {}, 715 + "sequences": {}, 716 + "roles": {}, 717 + "policies": {}, 718 + "views": {}, 719 + "_meta": { 720 + "columns": {}, 721 + "schemas": {}, 722 + "tables": {} 723 + } 724 + }
+724
src/shared/infrastructure/database/migrations/meta/0005_snapshot.json
··· 1 + { 2 + "id": "2d3157fe-869f-46ed-bcdc-03fc757f16c1", 3 + "prevId": "1e15b3e8-04ba-4e0a-965e-07142597ddc4", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "parent_card_id": { 87 + "name": "parent_card_id", 88 + "type": "uuid", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "published_record_id": { 93 + "name": "published_record_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "library_count": { 99 + "name": "library_count", 100 + "type": "integer", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "default": 0 104 + }, 105 + "created_at": { 106 + "name": "created_at", 107 + "type": "timestamp", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "default": "now()" 111 + }, 112 + "updated_at": { 113 + "name": "updated_at", 114 + "type": "timestamp", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "default": "now()" 118 + } 119 + }, 120 + "indexes": {}, 121 + "foreignKeys": { 122 + "cards_parent_card_id_cards_id_fk": { 123 + "name": "cards_parent_card_id_cards_id_fk", 124 + "tableFrom": "cards", 125 + "columnsFrom": ["parent_card_id"], 126 + "tableTo": "cards", 127 + "columnsTo": ["id"], 128 + "onUpdate": "no action", 129 + "onDelete": "no action" 130 + }, 131 + "cards_published_record_id_published_records_id_fk": { 132 + "name": "cards_published_record_id_published_records_id_fk", 133 + "tableFrom": "cards", 134 + "columnsFrom": ["published_record_id"], 135 + "tableTo": "published_records", 136 + "columnsTo": ["id"], 137 + "onUpdate": "no action", 138 + "onDelete": "no action" 139 + } 140 + }, 141 + "compositePrimaryKeys": {}, 142 + "uniqueConstraints": {}, 143 + "policies": {}, 144 + "checkConstraints": {}, 145 + "isRLSEnabled": false 146 + }, 147 + "public.collection_cards": { 148 + "name": "collection_cards", 149 + "schema": "", 150 + "columns": { 151 + "id": { 152 + "name": "id", 153 + "type": "uuid", 154 + "primaryKey": true, 155 + "notNull": true 156 + }, 157 + "collection_id": { 158 + "name": "collection_id", 159 + "type": "uuid", 160 + "primaryKey": false, 161 + "notNull": true 162 + }, 163 + "card_id": { 164 + "name": "card_id", 165 + "type": "uuid", 166 + "primaryKey": false, 167 + "notNull": true 168 + }, 169 + "added_by": { 170 + "name": "added_by", 171 + "type": "text", 172 + "primaryKey": false, 173 + "notNull": true 174 + }, 175 + "added_at": { 176 + "name": "added_at", 177 + "type": "timestamp", 178 + "primaryKey": false, 179 + "notNull": true, 180 + "default": "now()" 181 + }, 182 + "published_record_id": { 183 + "name": "published_record_id", 184 + "type": "uuid", 185 + "primaryKey": false, 186 + "notNull": false 187 + } 188 + }, 189 + "indexes": {}, 190 + "foreignKeys": { 191 + "collection_cards_collection_id_collections_id_fk": { 192 + "name": "collection_cards_collection_id_collections_id_fk", 193 + "tableFrom": "collection_cards", 194 + "columnsFrom": ["collection_id"], 195 + "tableTo": "collections", 196 + "columnsTo": ["id"], 197 + "onUpdate": "no action", 198 + "onDelete": "cascade" 199 + }, 200 + "collection_cards_card_id_cards_id_fk": { 201 + "name": "collection_cards_card_id_cards_id_fk", 202 + "tableFrom": "collection_cards", 203 + "columnsFrom": ["card_id"], 204 + "tableTo": "cards", 205 + "columnsTo": ["id"], 206 + "onUpdate": "no action", 207 + "onDelete": "cascade" 208 + }, 209 + "collection_cards_published_record_id_published_records_id_fk": { 210 + "name": "collection_cards_published_record_id_published_records_id_fk", 211 + "tableFrom": "collection_cards", 212 + "columnsFrom": ["published_record_id"], 213 + "tableTo": "published_records", 214 + "columnsTo": ["id"], 215 + "onUpdate": "no action", 216 + "onDelete": "no action" 217 + } 218 + }, 219 + "compositePrimaryKeys": {}, 220 + "uniqueConstraints": {}, 221 + "policies": {}, 222 + "checkConstraints": {}, 223 + "isRLSEnabled": false 224 + }, 225 + "public.collection_collaborators": { 226 + "name": "collection_collaborators", 227 + "schema": "", 228 + "columns": { 229 + "id": { 230 + "name": "id", 231 + "type": "uuid", 232 + "primaryKey": true, 233 + "notNull": true 234 + }, 235 + "collection_id": { 236 + "name": "collection_id", 237 + "type": "uuid", 238 + "primaryKey": false, 239 + "notNull": true 240 + }, 241 + "collaborator_id": { 242 + "name": "collaborator_id", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": true 246 + } 247 + }, 248 + "indexes": {}, 249 + "foreignKeys": { 250 + "collection_collaborators_collection_id_collections_id_fk": { 251 + "name": "collection_collaborators_collection_id_collections_id_fk", 252 + "tableFrom": "collection_collaborators", 253 + "columnsFrom": ["collection_id"], 254 + "tableTo": "collections", 255 + "columnsTo": ["id"], 256 + "onUpdate": "no action", 257 + "onDelete": "cascade" 258 + } 259 + }, 260 + "compositePrimaryKeys": {}, 261 + "uniqueConstraints": {}, 262 + "policies": {}, 263 + "checkConstraints": {}, 264 + "isRLSEnabled": false 265 + }, 266 + "public.collections": { 267 + "name": "collections", 268 + "schema": "", 269 + "columns": { 270 + "id": { 271 + "name": "id", 272 + "type": "uuid", 273 + "primaryKey": true, 274 + "notNull": true 275 + }, 276 + "author_id": { 277 + "name": "author_id", 278 + "type": "text", 279 + "primaryKey": false, 280 + "notNull": true 281 + }, 282 + "name": { 283 + "name": "name", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true 287 + }, 288 + "description": { 289 + "name": "description", 290 + "type": "text", 291 + "primaryKey": false, 292 + "notNull": false 293 + }, 294 + "access_type": { 295 + "name": "access_type", 296 + "type": "text", 297 + "primaryKey": false, 298 + "notNull": true 299 + }, 300 + "card_count": { 301 + "name": "card_count", 302 + "type": "integer", 303 + "primaryKey": false, 304 + "notNull": true, 305 + "default": 0 306 + }, 307 + "created_at": { 308 + "name": "created_at", 309 + "type": "timestamp", 310 + "primaryKey": false, 311 + "notNull": true, 312 + "default": "now()" 313 + }, 314 + "updated_at": { 315 + "name": "updated_at", 316 + "type": "timestamp", 317 + "primaryKey": false, 318 + "notNull": true, 319 + "default": "now()" 320 + }, 321 + "published_record_id": { 322 + "name": "published_record_id", 323 + "type": "uuid", 324 + "primaryKey": false, 325 + "notNull": false 326 + } 327 + }, 328 + "indexes": {}, 329 + "foreignKeys": { 330 + "collections_published_record_id_published_records_id_fk": { 331 + "name": "collections_published_record_id_published_records_id_fk", 332 + "tableFrom": "collections", 333 + "columnsFrom": ["published_record_id"], 334 + "tableTo": "published_records", 335 + "columnsTo": ["id"], 336 + "onUpdate": "no action", 337 + "onDelete": "no action" 338 + } 339 + }, 340 + "compositePrimaryKeys": {}, 341 + "uniqueConstraints": {}, 342 + "policies": {}, 343 + "checkConstraints": {}, 344 + "isRLSEnabled": false 345 + }, 346 + "public.library_memberships": { 347 + "name": "library_memberships", 348 + "schema": "", 349 + "columns": { 350 + "card_id": { 351 + "name": "card_id", 352 + "type": "uuid", 353 + "primaryKey": false, 354 + "notNull": true 355 + }, 356 + "user_id": { 357 + "name": "user_id", 358 + "type": "text", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "added_at": { 363 + "name": "added_at", 364 + "type": "timestamp", 365 + "primaryKey": false, 366 + "notNull": true, 367 + "default": "now()" 368 + }, 369 + "published_record_id": { 370 + "name": "published_record_id", 371 + "type": "uuid", 372 + "primaryKey": false, 373 + "notNull": false 374 + } 375 + }, 376 + "indexes": { 377 + "idx_user_cards": { 378 + "name": "idx_user_cards", 379 + "columns": [ 380 + { 381 + "expression": "user_id", 382 + "isExpression": false, 383 + "asc": true, 384 + "nulls": "last" 385 + } 386 + ], 387 + "isUnique": false, 388 + "with": {}, 389 + "method": "btree", 390 + "concurrently": false 391 + }, 392 + "idx_card_users": { 393 + "name": "idx_card_users", 394 + "columns": [ 395 + { 396 + "expression": "card_id", 397 + "isExpression": false, 398 + "asc": true, 399 + "nulls": "last" 400 + } 401 + ], 402 + "isUnique": false, 403 + "with": {}, 404 + "method": "btree", 405 + "concurrently": false 406 + } 407 + }, 408 + "foreignKeys": { 409 + "library_memberships_card_id_cards_id_fk": { 410 + "name": "library_memberships_card_id_cards_id_fk", 411 + "tableFrom": "library_memberships", 412 + "columnsFrom": ["card_id"], 413 + "tableTo": "cards", 414 + "columnsTo": ["id"], 415 + "onUpdate": "no action", 416 + "onDelete": "cascade" 417 + }, 418 + "library_memberships_published_record_id_published_records_id_fk": { 419 + "name": "library_memberships_published_record_id_published_records_id_fk", 420 + "tableFrom": "library_memberships", 421 + "columnsFrom": ["published_record_id"], 422 + "tableTo": "published_records", 423 + "columnsTo": ["id"], 424 + "onUpdate": "no action", 425 + "onDelete": "no action" 426 + } 427 + }, 428 + "compositePrimaryKeys": { 429 + "library_memberships_card_id_user_id_pk": { 430 + "name": "library_memberships_card_id_user_id_pk", 431 + "columns": ["card_id", "user_id"] 432 + } 433 + }, 434 + "uniqueConstraints": {}, 435 + "policies": {}, 436 + "checkConstraints": {}, 437 + "isRLSEnabled": false 438 + }, 439 + "public.published_records": { 440 + "name": "published_records", 441 + "schema": "", 442 + "columns": { 443 + "id": { 444 + "name": "id", 445 + "type": "uuid", 446 + "primaryKey": true, 447 + "notNull": true 448 + }, 449 + "uri": { 450 + "name": "uri", 451 + "type": "text", 452 + "primaryKey": false, 453 + "notNull": true 454 + }, 455 + "cid": { 456 + "name": "cid", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "recorded_at": { 462 + "name": "recorded_at", 463 + "type": "timestamp", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "default": "now()" 467 + } 468 + }, 469 + "indexes": { 470 + "uri_cid_unique_idx": { 471 + "name": "uri_cid_unique_idx", 472 + "columns": [ 473 + { 474 + "expression": "uri", 475 + "isExpression": false, 476 + "asc": true, 477 + "nulls": "last" 478 + }, 479 + { 480 + "expression": "cid", 481 + "isExpression": false, 482 + "asc": true, 483 + "nulls": "last" 484 + } 485 + ], 486 + "isUnique": true, 487 + "with": {}, 488 + "method": "btree", 489 + "concurrently": false 490 + }, 491 + "published_records_uri_idx": { 492 + "name": "published_records_uri_idx", 493 + "columns": [ 494 + { 495 + "expression": "uri", 496 + "isExpression": false, 497 + "asc": true, 498 + "nulls": "last" 499 + } 500 + ], 501 + "isUnique": false, 502 + "with": {}, 503 + "method": "btree", 504 + "concurrently": false 505 + } 506 + }, 507 + "foreignKeys": {}, 508 + "compositePrimaryKeys": {}, 509 + "uniqueConstraints": {}, 510 + "policies": {}, 511 + "checkConstraints": {}, 512 + "isRLSEnabled": false 513 + }, 514 + "public.feed_activities": { 515 + "name": "feed_activities", 516 + "schema": "", 517 + "columns": { 518 + "id": { 519 + "name": "id", 520 + "type": "uuid", 521 + "primaryKey": true, 522 + "notNull": true 523 + }, 524 + "actor_id": { 525 + "name": "actor_id", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": true 529 + }, 530 + "type": { 531 + "name": "type", 532 + "type": "text", 533 + "primaryKey": false, 534 + "notNull": true 535 + }, 536 + "metadata": { 537 + "name": "metadata", 538 + "type": "jsonb", 539 + "primaryKey": false, 540 + "notNull": true 541 + }, 542 + "created_at": { 543 + "name": "created_at", 544 + "type": "timestamp", 545 + "primaryKey": false, 546 + "notNull": true, 547 + "default": "now()" 548 + } 549 + }, 550 + "indexes": {}, 551 + "foreignKeys": {}, 552 + "compositePrimaryKeys": {}, 553 + "uniqueConstraints": {}, 554 + "policies": {}, 555 + "checkConstraints": {}, 556 + "isRLSEnabled": false 557 + }, 558 + "public.auth_session": { 559 + "name": "auth_session", 560 + "schema": "", 561 + "columns": { 562 + "key": { 563 + "name": "key", 564 + "type": "text", 565 + "primaryKey": true, 566 + "notNull": true 567 + }, 568 + "session": { 569 + "name": "session", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + } 574 + }, 575 + "indexes": {}, 576 + "foreignKeys": {}, 577 + "compositePrimaryKeys": {}, 578 + "uniqueConstraints": {}, 579 + "policies": {}, 580 + "checkConstraints": {}, 581 + "isRLSEnabled": false 582 + }, 583 + "public.auth_state": { 584 + "name": "auth_state", 585 + "schema": "", 586 + "columns": { 587 + "key": { 588 + "name": "key", 589 + "type": "text", 590 + "primaryKey": true, 591 + "notNull": true 592 + }, 593 + "state": { 594 + "name": "state", 595 + "type": "text", 596 + "primaryKey": false, 597 + "notNull": true 598 + }, 599 + "created_at": { 600 + "name": "created_at", 601 + "type": "timestamp", 602 + "primaryKey": false, 603 + "notNull": false, 604 + "default": "now()" 605 + } 606 + }, 607 + "indexes": {}, 608 + "foreignKeys": {}, 609 + "compositePrimaryKeys": {}, 610 + "uniqueConstraints": {}, 611 + "policies": {}, 612 + "checkConstraints": {}, 613 + "isRLSEnabled": false 614 + }, 615 + "public.auth_refresh_tokens": { 616 + "name": "auth_refresh_tokens", 617 + "schema": "", 618 + "columns": { 619 + "token_id": { 620 + "name": "token_id", 621 + "type": "text", 622 + "primaryKey": true, 623 + "notNull": true 624 + }, 625 + "user_did": { 626 + "name": "user_did", 627 + "type": "text", 628 + "primaryKey": false, 629 + "notNull": true 630 + }, 631 + "refresh_token": { 632 + "name": "refresh_token", 633 + "type": "text", 634 + "primaryKey": false, 635 + "notNull": true 636 + }, 637 + "issued_at": { 638 + "name": "issued_at", 639 + "type": "timestamp", 640 + "primaryKey": false, 641 + "notNull": true 642 + }, 643 + "expires_at": { 644 + "name": "expires_at", 645 + "type": "timestamp", 646 + "primaryKey": false, 647 + "notNull": true 648 + }, 649 + "revoked": { 650 + "name": "revoked", 651 + "type": "boolean", 652 + "primaryKey": false, 653 + "notNull": false, 654 + "default": false 655 + } 656 + }, 657 + "indexes": {}, 658 + "foreignKeys": { 659 + "auth_refresh_tokens_user_did_users_id_fk": { 660 + "name": "auth_refresh_tokens_user_did_users_id_fk", 661 + "tableFrom": "auth_refresh_tokens", 662 + "columnsFrom": ["user_did"], 663 + "tableTo": "users", 664 + "columnsTo": ["id"], 665 + "onUpdate": "no action", 666 + "onDelete": "no action" 667 + } 668 + }, 669 + "compositePrimaryKeys": {}, 670 + "uniqueConstraints": {}, 671 + "policies": {}, 672 + "checkConstraints": {}, 673 + "isRLSEnabled": false 674 + }, 675 + "public.users": { 676 + "name": "users", 677 + "schema": "", 678 + "columns": { 679 + "id": { 680 + "name": "id", 681 + "type": "text", 682 + "primaryKey": true, 683 + "notNull": true 684 + }, 685 + "handle": { 686 + "name": "handle", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": false 690 + }, 691 + "linked_at": { 692 + "name": "linked_at", 693 + "type": "timestamp", 694 + "primaryKey": false, 695 + "notNull": true 696 + }, 697 + "last_login_at": { 698 + "name": "last_login_at", 699 + "type": "timestamp", 700 + "primaryKey": false, 701 + "notNull": true 702 + } 703 + }, 704 + "indexes": {}, 705 + "foreignKeys": {}, 706 + "compositePrimaryKeys": {}, 707 + "uniqueConstraints": {}, 708 + "policies": {}, 709 + "checkConstraints": {}, 710 + "isRLSEnabled": false 711 + } 712 + }, 713 + "enums": {}, 714 + "schemas": {}, 715 + "views": {}, 716 + "sequences": {}, 717 + "roles": {}, 718 + "policies": {}, 719 + "_meta": { 720 + "columns": {}, 721 + "schemas": {}, 722 + "tables": {} 723 + } 724 + }
+829
src/shared/infrastructure/database/migrations/meta/0006_snapshot.json
··· 1 + { 2 + "id": "8d99a964-b5d3-497c-9b09-42a60ce3b09e", 3 + "prevId": "2d3157fe-869f-46ed-bcdc-03fc757f16c1", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "parent_card_id": { 87 + "name": "parent_card_id", 88 + "type": "uuid", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "published_record_id": { 93 + "name": "published_record_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "library_count": { 99 + "name": "library_count", 100 + "type": "integer", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "default": 0 104 + }, 105 + "created_at": { 106 + "name": "created_at", 107 + "type": "timestamp", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "default": "now()" 111 + }, 112 + "updated_at": { 113 + "name": "updated_at", 114 + "type": "timestamp", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "default": "now()" 118 + } 119 + }, 120 + "indexes": { 121 + "cards_author_url_idx": { 122 + "name": "cards_author_url_idx", 123 + "columns": [ 124 + { 125 + "expression": "author_id", 126 + "isExpression": false, 127 + "asc": true, 128 + "nulls": "last" 129 + }, 130 + { 131 + "expression": "url", 132 + "isExpression": false, 133 + "asc": true, 134 + "nulls": "last" 135 + } 136 + ], 137 + "isUnique": false, 138 + "concurrently": false, 139 + "method": "btree", 140 + "with": {} 141 + }, 142 + "cards_author_id_idx": { 143 + "name": "cards_author_id_idx", 144 + "columns": [ 145 + { 146 + "expression": "author_id", 147 + "isExpression": false, 148 + "asc": true, 149 + "nulls": "last" 150 + } 151 + ], 152 + "isUnique": false, 153 + "concurrently": false, 154 + "method": "btree", 155 + "with": {} 156 + } 157 + }, 158 + "foreignKeys": { 159 + "cards_parent_card_id_cards_id_fk": { 160 + "name": "cards_parent_card_id_cards_id_fk", 161 + "tableFrom": "cards", 162 + "tableTo": "cards", 163 + "columnsFrom": ["parent_card_id"], 164 + "columnsTo": ["id"], 165 + "onDelete": "no action", 166 + "onUpdate": "no action" 167 + }, 168 + "cards_published_record_id_published_records_id_fk": { 169 + "name": "cards_published_record_id_published_records_id_fk", 170 + "tableFrom": "cards", 171 + "tableTo": "published_records", 172 + "columnsFrom": ["published_record_id"], 173 + "columnsTo": ["id"], 174 + "onDelete": "no action", 175 + "onUpdate": "no action" 176 + } 177 + }, 178 + "compositePrimaryKeys": {}, 179 + "uniqueConstraints": {}, 180 + "policies": {}, 181 + "checkConstraints": {}, 182 + "isRLSEnabled": false 183 + }, 184 + "public.collection_cards": { 185 + "name": "collection_cards", 186 + "schema": "", 187 + "columns": { 188 + "id": { 189 + "name": "id", 190 + "type": "uuid", 191 + "primaryKey": true, 192 + "notNull": true 193 + }, 194 + "collection_id": { 195 + "name": "collection_id", 196 + "type": "uuid", 197 + "primaryKey": false, 198 + "notNull": true 199 + }, 200 + "card_id": { 201 + "name": "card_id", 202 + "type": "uuid", 203 + "primaryKey": false, 204 + "notNull": true 205 + }, 206 + "added_by": { 207 + "name": "added_by", 208 + "type": "text", 209 + "primaryKey": false, 210 + "notNull": true 211 + }, 212 + "added_at": { 213 + "name": "added_at", 214 + "type": "timestamp", 215 + "primaryKey": false, 216 + "notNull": true, 217 + "default": "now()" 218 + }, 219 + "published_record_id": { 220 + "name": "published_record_id", 221 + "type": "uuid", 222 + "primaryKey": false, 223 + "notNull": false 224 + } 225 + }, 226 + "indexes": { 227 + "collection_cards_card_id_idx": { 228 + "name": "collection_cards_card_id_idx", 229 + "columns": [ 230 + { 231 + "expression": "card_id", 232 + "isExpression": false, 233 + "asc": true, 234 + "nulls": "last" 235 + } 236 + ], 237 + "isUnique": false, 238 + "concurrently": false, 239 + "method": "btree", 240 + "with": {} 241 + }, 242 + "collection_cards_collection_id_idx": { 243 + "name": "collection_cards_collection_id_idx", 244 + "columns": [ 245 + { 246 + "expression": "collection_id", 247 + "isExpression": false, 248 + "asc": true, 249 + "nulls": "last" 250 + } 251 + ], 252 + "isUnique": false, 253 + "concurrently": false, 254 + "method": "btree", 255 + "with": {} 256 + } 257 + }, 258 + "foreignKeys": { 259 + "collection_cards_collection_id_collections_id_fk": { 260 + "name": "collection_cards_collection_id_collections_id_fk", 261 + "tableFrom": "collection_cards", 262 + "tableTo": "collections", 263 + "columnsFrom": ["collection_id"], 264 + "columnsTo": ["id"], 265 + "onDelete": "cascade", 266 + "onUpdate": "no action" 267 + }, 268 + "collection_cards_card_id_cards_id_fk": { 269 + "name": "collection_cards_card_id_cards_id_fk", 270 + "tableFrom": "collection_cards", 271 + "tableTo": "cards", 272 + "columnsFrom": ["card_id"], 273 + "columnsTo": ["id"], 274 + "onDelete": "cascade", 275 + "onUpdate": "no action" 276 + }, 277 + "collection_cards_published_record_id_published_records_id_fk": { 278 + "name": "collection_cards_published_record_id_published_records_id_fk", 279 + "tableFrom": "collection_cards", 280 + "tableTo": "published_records", 281 + "columnsFrom": ["published_record_id"], 282 + "columnsTo": ["id"], 283 + "onDelete": "no action", 284 + "onUpdate": "no action" 285 + } 286 + }, 287 + "compositePrimaryKeys": {}, 288 + "uniqueConstraints": {}, 289 + "policies": {}, 290 + "checkConstraints": {}, 291 + "isRLSEnabled": false 292 + }, 293 + "public.collection_collaborators": { 294 + "name": "collection_collaborators", 295 + "schema": "", 296 + "columns": { 297 + "id": { 298 + "name": "id", 299 + "type": "uuid", 300 + "primaryKey": true, 301 + "notNull": true 302 + }, 303 + "collection_id": { 304 + "name": "collection_id", 305 + "type": "uuid", 306 + "primaryKey": false, 307 + "notNull": true 308 + }, 309 + "collaborator_id": { 310 + "name": "collaborator_id", 311 + "type": "text", 312 + "primaryKey": false, 313 + "notNull": true 314 + } 315 + }, 316 + "indexes": {}, 317 + "foreignKeys": { 318 + "collection_collaborators_collection_id_collections_id_fk": { 319 + "name": "collection_collaborators_collection_id_collections_id_fk", 320 + "tableFrom": "collection_collaborators", 321 + "tableTo": "collections", 322 + "columnsFrom": ["collection_id"], 323 + "columnsTo": ["id"], 324 + "onDelete": "cascade", 325 + "onUpdate": "no action" 326 + } 327 + }, 328 + "compositePrimaryKeys": {}, 329 + "uniqueConstraints": {}, 330 + "policies": {}, 331 + "checkConstraints": {}, 332 + "isRLSEnabled": false 333 + }, 334 + "public.collections": { 335 + "name": "collections", 336 + "schema": "", 337 + "columns": { 338 + "id": { 339 + "name": "id", 340 + "type": "uuid", 341 + "primaryKey": true, 342 + "notNull": true 343 + }, 344 + "author_id": { 345 + "name": "author_id", 346 + "type": "text", 347 + "primaryKey": false, 348 + "notNull": true 349 + }, 350 + "name": { 351 + "name": "name", 352 + "type": "text", 353 + "primaryKey": false, 354 + "notNull": true 355 + }, 356 + "description": { 357 + "name": "description", 358 + "type": "text", 359 + "primaryKey": false, 360 + "notNull": false 361 + }, 362 + "access_type": { 363 + "name": "access_type", 364 + "type": "text", 365 + "primaryKey": false, 366 + "notNull": true 367 + }, 368 + "card_count": { 369 + "name": "card_count", 370 + "type": "integer", 371 + "primaryKey": false, 372 + "notNull": true, 373 + "default": 0 374 + }, 375 + "created_at": { 376 + "name": "created_at", 377 + "type": "timestamp", 378 + "primaryKey": false, 379 + "notNull": true, 380 + "default": "now()" 381 + }, 382 + "updated_at": { 383 + "name": "updated_at", 384 + "type": "timestamp", 385 + "primaryKey": false, 386 + "notNull": true, 387 + "default": "now()" 388 + }, 389 + "published_record_id": { 390 + "name": "published_record_id", 391 + "type": "uuid", 392 + "primaryKey": false, 393 + "notNull": false 394 + } 395 + }, 396 + "indexes": { 397 + "collections_author_id_idx": { 398 + "name": "collections_author_id_idx", 399 + "columns": [ 400 + { 401 + "expression": "author_id", 402 + "isExpression": false, 403 + "asc": true, 404 + "nulls": "last" 405 + } 406 + ], 407 + "isUnique": false, 408 + "concurrently": false, 409 + "method": "btree", 410 + "with": {} 411 + }, 412 + "collections_author_updated_at_idx": { 413 + "name": "collections_author_updated_at_idx", 414 + "columns": [ 415 + { 416 + "expression": "author_id", 417 + "isExpression": false, 418 + "asc": true, 419 + "nulls": "last" 420 + }, 421 + { 422 + "expression": "updated_at", 423 + "isExpression": false, 424 + "asc": true, 425 + "nulls": "last" 426 + } 427 + ], 428 + "isUnique": false, 429 + "concurrently": false, 430 + "method": "btree", 431 + "with": {} 432 + } 433 + }, 434 + "foreignKeys": { 435 + "collections_published_record_id_published_records_id_fk": { 436 + "name": "collections_published_record_id_published_records_id_fk", 437 + "tableFrom": "collections", 438 + "tableTo": "published_records", 439 + "columnsFrom": ["published_record_id"], 440 + "columnsTo": ["id"], 441 + "onDelete": "no action", 442 + "onUpdate": "no action" 443 + } 444 + }, 445 + "compositePrimaryKeys": {}, 446 + "uniqueConstraints": {}, 447 + "policies": {}, 448 + "checkConstraints": {}, 449 + "isRLSEnabled": false 450 + }, 451 + "public.library_memberships": { 452 + "name": "library_memberships", 453 + "schema": "", 454 + "columns": { 455 + "card_id": { 456 + "name": "card_id", 457 + "type": "uuid", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "user_id": { 462 + "name": "user_id", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "added_at": { 468 + "name": "added_at", 469 + "type": "timestamp", 470 + "primaryKey": false, 471 + "notNull": true, 472 + "default": "now()" 473 + }, 474 + "published_record_id": { 475 + "name": "published_record_id", 476 + "type": "uuid", 477 + "primaryKey": false, 478 + "notNull": false 479 + } 480 + }, 481 + "indexes": { 482 + "idx_user_cards": { 483 + "name": "idx_user_cards", 484 + "columns": [ 485 + { 486 + "expression": "user_id", 487 + "isExpression": false, 488 + "asc": true, 489 + "nulls": "last" 490 + } 491 + ], 492 + "isUnique": false, 493 + "concurrently": false, 494 + "method": "btree", 495 + "with": {} 496 + }, 497 + "idx_card_users": { 498 + "name": "idx_card_users", 499 + "columns": [ 500 + { 501 + "expression": "card_id", 502 + "isExpression": false, 503 + "asc": true, 504 + "nulls": "last" 505 + } 506 + ], 507 + "isUnique": false, 508 + "concurrently": false, 509 + "method": "btree", 510 + "with": {} 511 + } 512 + }, 513 + "foreignKeys": { 514 + "library_memberships_card_id_cards_id_fk": { 515 + "name": "library_memberships_card_id_cards_id_fk", 516 + "tableFrom": "library_memberships", 517 + "tableTo": "cards", 518 + "columnsFrom": ["card_id"], 519 + "columnsTo": ["id"], 520 + "onDelete": "cascade", 521 + "onUpdate": "no action" 522 + }, 523 + "library_memberships_published_record_id_published_records_id_fk": { 524 + "name": "library_memberships_published_record_id_published_records_id_fk", 525 + "tableFrom": "library_memberships", 526 + "tableTo": "published_records", 527 + "columnsFrom": ["published_record_id"], 528 + "columnsTo": ["id"], 529 + "onDelete": "no action", 530 + "onUpdate": "no action" 531 + } 532 + }, 533 + "compositePrimaryKeys": { 534 + "library_memberships_card_id_user_id_pk": { 535 + "name": "library_memberships_card_id_user_id_pk", 536 + "columns": ["card_id", "user_id"] 537 + } 538 + }, 539 + "uniqueConstraints": {}, 540 + "policies": {}, 541 + "checkConstraints": {}, 542 + "isRLSEnabled": false 543 + }, 544 + "public.published_records": { 545 + "name": "published_records", 546 + "schema": "", 547 + "columns": { 548 + "id": { 549 + "name": "id", 550 + "type": "uuid", 551 + "primaryKey": true, 552 + "notNull": true 553 + }, 554 + "uri": { 555 + "name": "uri", 556 + "type": "text", 557 + "primaryKey": false, 558 + "notNull": true 559 + }, 560 + "cid": { 561 + "name": "cid", 562 + "type": "text", 563 + "primaryKey": false, 564 + "notNull": true 565 + }, 566 + "recorded_at": { 567 + "name": "recorded_at", 568 + "type": "timestamp", 569 + "primaryKey": false, 570 + "notNull": true, 571 + "default": "now()" 572 + } 573 + }, 574 + "indexes": { 575 + "uri_cid_unique_idx": { 576 + "name": "uri_cid_unique_idx", 577 + "columns": [ 578 + { 579 + "expression": "uri", 580 + "isExpression": false, 581 + "asc": true, 582 + "nulls": "last" 583 + }, 584 + { 585 + "expression": "cid", 586 + "isExpression": false, 587 + "asc": true, 588 + "nulls": "last" 589 + } 590 + ], 591 + "isUnique": true, 592 + "concurrently": false, 593 + "method": "btree", 594 + "with": {} 595 + }, 596 + "published_records_uri_idx": { 597 + "name": "published_records_uri_idx", 598 + "columns": [ 599 + { 600 + "expression": "uri", 601 + "isExpression": false, 602 + "asc": true, 603 + "nulls": "last" 604 + } 605 + ], 606 + "isUnique": false, 607 + "concurrently": false, 608 + "method": "btree", 609 + "with": {} 610 + } 611 + }, 612 + "foreignKeys": {}, 613 + "compositePrimaryKeys": {}, 614 + "uniqueConstraints": {}, 615 + "policies": {}, 616 + "checkConstraints": {}, 617 + "isRLSEnabled": false 618 + }, 619 + "public.feed_activities": { 620 + "name": "feed_activities", 621 + "schema": "", 622 + "columns": { 623 + "id": { 624 + "name": "id", 625 + "type": "uuid", 626 + "primaryKey": true, 627 + "notNull": true 628 + }, 629 + "actor_id": { 630 + "name": "actor_id", 631 + "type": "text", 632 + "primaryKey": false, 633 + "notNull": true 634 + }, 635 + "type": { 636 + "name": "type", 637 + "type": "text", 638 + "primaryKey": false, 639 + "notNull": true 640 + }, 641 + "metadata": { 642 + "name": "metadata", 643 + "type": "jsonb", 644 + "primaryKey": false, 645 + "notNull": true 646 + }, 647 + "created_at": { 648 + "name": "created_at", 649 + "type": "timestamp", 650 + "primaryKey": false, 651 + "notNull": true, 652 + "default": "now()" 653 + } 654 + }, 655 + "indexes": {}, 656 + "foreignKeys": {}, 657 + "compositePrimaryKeys": {}, 658 + "uniqueConstraints": {}, 659 + "policies": {}, 660 + "checkConstraints": {}, 661 + "isRLSEnabled": false 662 + }, 663 + "public.auth_session": { 664 + "name": "auth_session", 665 + "schema": "", 666 + "columns": { 667 + "key": { 668 + "name": "key", 669 + "type": "text", 670 + "primaryKey": true, 671 + "notNull": true 672 + }, 673 + "session": { 674 + "name": "session", 675 + "type": "text", 676 + "primaryKey": false, 677 + "notNull": true 678 + } 679 + }, 680 + "indexes": {}, 681 + "foreignKeys": {}, 682 + "compositePrimaryKeys": {}, 683 + "uniqueConstraints": {}, 684 + "policies": {}, 685 + "checkConstraints": {}, 686 + "isRLSEnabled": false 687 + }, 688 + "public.auth_state": { 689 + "name": "auth_state", 690 + "schema": "", 691 + "columns": { 692 + "key": { 693 + "name": "key", 694 + "type": "text", 695 + "primaryKey": true, 696 + "notNull": true 697 + }, 698 + "state": { 699 + "name": "state", 700 + "type": "text", 701 + "primaryKey": false, 702 + "notNull": true 703 + }, 704 + "created_at": { 705 + "name": "created_at", 706 + "type": "timestamp", 707 + "primaryKey": false, 708 + "notNull": false, 709 + "default": "now()" 710 + } 711 + }, 712 + "indexes": {}, 713 + "foreignKeys": {}, 714 + "compositePrimaryKeys": {}, 715 + "uniqueConstraints": {}, 716 + "policies": {}, 717 + "checkConstraints": {}, 718 + "isRLSEnabled": false 719 + }, 720 + "public.auth_refresh_tokens": { 721 + "name": "auth_refresh_tokens", 722 + "schema": "", 723 + "columns": { 724 + "token_id": { 725 + "name": "token_id", 726 + "type": "text", 727 + "primaryKey": true, 728 + "notNull": true 729 + }, 730 + "user_did": { 731 + "name": "user_did", 732 + "type": "text", 733 + "primaryKey": false, 734 + "notNull": true 735 + }, 736 + "refresh_token": { 737 + "name": "refresh_token", 738 + "type": "text", 739 + "primaryKey": false, 740 + "notNull": true 741 + }, 742 + "issued_at": { 743 + "name": "issued_at", 744 + "type": "timestamp", 745 + "primaryKey": false, 746 + "notNull": true 747 + }, 748 + "expires_at": { 749 + "name": "expires_at", 750 + "type": "timestamp", 751 + "primaryKey": false, 752 + "notNull": true 753 + }, 754 + "revoked": { 755 + "name": "revoked", 756 + "type": "boolean", 757 + "primaryKey": false, 758 + "notNull": false, 759 + "default": false 760 + } 761 + }, 762 + "indexes": {}, 763 + "foreignKeys": { 764 + "auth_refresh_tokens_user_did_users_id_fk": { 765 + "name": "auth_refresh_tokens_user_did_users_id_fk", 766 + "tableFrom": "auth_refresh_tokens", 767 + "tableTo": "users", 768 + "columnsFrom": ["user_did"], 769 + "columnsTo": ["id"], 770 + "onDelete": "no action", 771 + "onUpdate": "no action" 772 + } 773 + }, 774 + "compositePrimaryKeys": {}, 775 + "uniqueConstraints": {}, 776 + "policies": {}, 777 + "checkConstraints": {}, 778 + "isRLSEnabled": false 779 + }, 780 + "public.users": { 781 + "name": "users", 782 + "schema": "", 783 + "columns": { 784 + "id": { 785 + "name": "id", 786 + "type": "text", 787 + "primaryKey": true, 788 + "notNull": true 789 + }, 790 + "handle": { 791 + "name": "handle", 792 + "type": "text", 793 + "primaryKey": false, 794 + "notNull": false 795 + }, 796 + "linked_at": { 797 + "name": "linked_at", 798 + "type": "timestamp", 799 + "primaryKey": false, 800 + "notNull": true 801 + }, 802 + "last_login_at": { 803 + "name": "last_login_at", 804 + "type": "timestamp", 805 + "primaryKey": false, 806 + "notNull": true 807 + } 808 + }, 809 + "indexes": {}, 810 + "foreignKeys": {}, 811 + "compositePrimaryKeys": {}, 812 + "uniqueConstraints": {}, 813 + "policies": {}, 814 + "checkConstraints": {}, 815 + "isRLSEnabled": false 816 + } 817 + }, 818 + "enums": {}, 819 + "schemas": {}, 820 + "sequences": {}, 821 + "roles": {}, 822 + "policies": {}, 823 + "views": {}, 824 + "_meta": { 825 + "columns": {}, 826 + "schemas": {}, 827 + "tables": {} 828 + } 829 + }
+21
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 29 29 "when": 1758934838107, 30 30 "tag": "0003_harsh_lady_mastermind", 31 31 "breakpoints": true 32 + }, 33 + { 34 + "idx": 4, 35 + "version": "7", 36 + "when": 1759875361391, 37 + "tag": "0004_brainy_rocket_racer", 38 + "breakpoints": true 39 + }, 40 + { 41 + "idx": 5, 42 + "version": "7", 43 + "when": 1759957391430, 44 + "tag": "0005_truncate-tables", 45 + "breakpoints": true 46 + }, 47 + { 48 + "idx": 6, 49 + "version": "7", 50 + "when": 1759962763890, 51 + "tag": "0006_lovely_randall", 52 + "breakpoints": true 32 53 } 33 54 ] 34 55 }
+1
src/shared/infrastructure/http/app.ts
··· 67 67 controllers.getLibrariesForCardController, 68 68 controllers.getMyUrlCardsController, 69 69 controllers.getUserUrlCardsController, 70 + controllers.getUrlStatusForMyLibraryController, 70 71 // Collection controllers 71 72 controllers.createCollectionController, 72 73 controllers.updateCollectionController,
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 26 26 import { GenerateExtensionTokensController } from 'src/modules/user/infrastructure/http/controllers/GenerateExtensionTokensController'; 27 27 import { GetUserCollectionsController } from 'src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController'; 28 28 import { GetCollectionPageByAtUriController } from 'src/modules/cards/infrastructure/http/controllers/GetCollectionPageByAtUriController'; 29 + import { GetUrlStatusForMyLibraryController } from '../../../../modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController'; 29 30 30 31 export interface Controllers { 31 32 // User controllers ··· 56 57 getCollectionPageByAtUriController: GetCollectionPageByAtUriController; 57 58 getMyCollectionsController: GetMyCollectionsController; 58 59 getCollectionsController: GetUserCollectionsController; 60 + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController; 59 61 // Feed controllers 60 62 getGlobalFeedController: GetGlobalFeedController; 61 63 } ··· 144 146 getCollectionsController: new GetUserCollectionsController( 145 147 useCases.getCollectionsUseCase, 146 148 ), 149 + getUrlStatusForMyLibraryController: 150 + new GetUrlStatusForMyLibraryController( 151 + useCases.getUrlStatusForMyLibraryUseCase, 152 + ), 147 153 148 154 // Feed controllers 149 155 getGlobalFeedController: new GetGlobalFeedController(
+12 -2
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 61 61 feedService: FeedService; 62 62 nodeOauthClient: NodeOAuthClient; 63 63 identityResolutionService: IIdentityResolutionService; 64 + configService: EnvironmentConfigService; 64 65 } 65 66 66 67 // Web app specific services (includes publishers, auth middleware) ··· 123 124 : new AtProtoOAuthProcessor(sharedServices.nodeOauthClient); 124 125 125 126 const useFakePublishers = process.env.USE_FAKE_PUBLISHERS === 'true'; 127 + const collections = configService.getAtProtoCollections(); 126 128 127 129 const collectionPublisher = useFakePublishers 128 130 ? new FakeCollectionPublisher() 129 - : new ATProtoCollectionPublisher(sharedServices.atProtoAgentService); 131 + : new ATProtoCollectionPublisher( 132 + sharedServices.atProtoAgentService, 133 + collections.collection, 134 + collections.collectionLink, 135 + ); 130 136 131 137 const cardPublisher = useFakePublishers 132 138 ? new FakeCardPublisher() 133 - : new ATProtoCardPublisher(sharedServices.atProtoAgentService); 139 + : new ATProtoCardPublisher( 140 + sharedServices.atProtoAgentService, 141 + collections.card, 142 + ); 134 143 135 144 const cardCollectionService = new CardCollectionService( 136 145 repositories.collectionRepository, ··· 287 296 feedService, 288 297 nodeOauthClient, 289 298 identityResolutionService, 299 + configService, 290 300 }; 291 301 } 292 302 }
+8
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 25 25 import { AddActivityToFeedUseCase } from '../../../../modules/feeds/application/useCases/commands/AddActivityToFeedUseCase'; 26 26 import { GetCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionsUseCase'; 27 27 import { GetCollectionPageByAtUriUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase'; 28 + import { GetUrlStatusForMyLibraryUseCase } from '../../../../modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 28 29 29 30 export interface UseCases { 30 31 // User use cases ··· 52 53 getCollectionPageUseCase: GetCollectionPageUseCase; 53 54 getCollectionPageByAtUriUseCase: GetCollectionPageByAtUriUseCase; 54 55 getCollectionsUseCase: GetCollectionsUseCase; 56 + getUrlStatusForMyLibraryUseCase: GetUrlStatusForMyLibraryUseCase; 55 57 // Feed use cases 56 58 getGlobalFeedUseCase: GetGlobalFeedUseCase; 57 59 addActivityToFeedUseCase: AddActivityToFeedUseCase; ··· 164 166 services.identityResolutionService, 165 167 repositories.atUriResolutionService, 166 168 getCollectionPageUseCase, 169 + services.configService.getAtProtoCollections().collection, 167 170 ), 168 171 getCollectionsUseCase: new GetCollectionsUseCase( 169 172 repositories.collectionQueryRepository, 170 173 services.profileService, 171 174 services.identityResolutionService, 175 + ), 176 + getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( 177 + repositories.cardRepository, 178 + repositories.collectionQueryRepository, 179 + services.eventPublisher, 172 180 ), 173 181 174 182 // Feed use cases
+9
src/webapp/api-client/ApiClient.ts
··· 57 57 GetUrlCardsResponse, 58 58 GetProfileResponse, 59 59 GetProfileParams, 60 + GetUrlStatusForMyLibraryParams, 61 + GetUrlStatusForMyLibraryResponse, 60 62 } from './types'; 61 63 62 64 // Main API Client class using composition ··· 150 152 params: GetCollectionsParams, 151 153 ): Promise<GetCollectionsResponse> { 152 154 return this.queryClient.getUserCollections(params); 155 + } 156 + 157 + async getUrlStatusForMyLibrary( 158 + params: GetUrlStatusForMyLibraryParams, 159 + ): Promise<GetUrlStatusForMyLibraryResponse> { 160 + this.requireAuthentication('getUrlStatusForMyLibrary'); 161 + return this.queryClient.getUrlStatusForMyLibrary(params); 153 162 } 154 163 155 164 // Card operations - delegate to CardClient (all require authentication)
+12
src/webapp/api-client/clients/QueryClient.ts
··· 7 7 GetProfileResponse, 8 8 GetCollectionPageResponse, 9 9 GetCollectionsResponse, 10 + GetUrlStatusForMyLibraryResponse, 10 11 GetMyUrlCardsParams, 11 12 GetUrlCardsParams, 12 13 GetCollectionPageParams, ··· 14 15 GetCollectionsParams, 15 16 GetCollectionPageByAtUriParams, 16 17 GetProfileParams, 18 + GetUrlStatusForMyLibraryParams, 17 19 } from '../types'; 18 20 19 21 export class QueryClient extends BaseClient { ··· 156 158 : `/api/collections/user/${params.identifier}`; 157 159 158 160 return this.request<GetCollectionsResponse>('GET', endpoint); 161 + } 162 + 163 + async getUrlStatusForMyLibrary( 164 + params: GetUrlStatusForMyLibraryParams, 165 + ): Promise<GetUrlStatusForMyLibraryResponse> { 166 + const searchParams = new URLSearchParams({ url: params.url }); 167 + return this.request<GetUrlStatusForMyLibraryResponse>( 168 + 'GET', 169 + `/api/cards/library/status?${searchParams}`, 170 + ); 159 171 } 160 172 }
+4
src/webapp/api-client/types/requests.ts
··· 127 127 export interface GetProfileParams { 128 128 identifier: string; // Can be DID or handle 129 129 } 130 + 131 + export interface GetUrlStatusForMyLibraryParams { 132 + url: string; 133 + }
+10 -1
src/webapp/api-client/types/responses.ts
··· 49 49 50 50 export interface GetUrlMetadataResponse { 51 51 metadata: UrlMetadata; 52 - existingCardId?: string; 53 52 } 54 53 55 54 export interface UrlCardView { ··· 298 297 nextCursor?: string; 299 298 }; 300 299 } 300 + 301 + export interface GetUrlStatusForMyLibraryResponse { 302 + cardId?: string; 303 + collections?: { 304 + id: string; 305 + uri?: string; 306 + name: string; 307 + description?: string; 308 + }[]; 309 + }
+4 -6
src/webapp/components/UrlCardForm.tsx
··· 47 47 // URL metadata hook 48 48 const { 49 49 metadata, 50 - existingCard, 50 + existingCardCollections, 51 51 loading: metadataLoading, 52 52 error: metadataError, 53 53 } = useUrlMetadata({ ··· 58 58 59 59 // Get existing collections for this card (filtered by current user) 60 60 const existingCollections = useMemo(() => { 61 - if (!existingCard || !userId) return []; 62 - return existingCard.collections.filter( 63 - (collection) => collection.authorId === userId, 64 - ); 65 - }, [existingCard, userId]); 61 + if (!existingCardCollections || !userId) return []; 62 + return existingCardCollections.map((c) => ({ ...c, authorId: userId })); 63 + }, [existingCardCollections, userId]); 66 64 67 65 const handleSubmit = async (e: React.FormEvent) => { 68 66 e.preventDefault();
+11 -17
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 15 15 Alert, 16 16 } from '@mantine/core'; 17 17 import { useDebouncedValue } from '@mantine/hooks'; 18 + import { notifications } from '@mantine/notifications'; 18 19 import { Fragment, useState } from 'react'; 19 20 import { IoSearch } from 'react-icons/io5'; 20 21 import { BiPlus } from 'react-icons/bi'; 21 - import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 22 - import useCard from '@/features/cards/lib/queries/useGetCard'; 23 - import { notifications } from '@mantine/notifications'; 24 22 import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; 25 23 import CollectionSelectorItemList from '../../../collections/components/collectionSelectorItemList/CollectionSelectorItemList'; 26 24 import CreateCollectionDrawer from '../../../collections/components/createCollectionDrawer/CreateCollectionDrawer'; 27 25 import CardToBeAddedPreview from './CardToBeAddedPreview'; 28 26 import useAddCardToLibrary from '../../lib/mutations/useAddCardToLibrary'; 29 - import useGetLibrariesForCard from '../../lib/queries/useGetLibrariesForcard'; 30 - import { useAuth } from '@/hooks/useAuth'; 27 + import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; 28 + import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 31 29 32 30 interface Props { 33 31 isOpen: boolean; ··· 37 35 } 38 36 39 37 export default function AddCardToModal(props: Props) { 40 - const { user, isLoading: isLoadingUser } = useAuth(); 41 38 const [isDrawerOpen, setIsDrawerOpen] = useState(false); 42 39 43 40 const [search, setSearch] = useState<string>(''); 44 41 const [debouncedSearch] = useDebouncedValue(search, 200); 45 42 const searchedCollections = useCollectionSearch({ query: debouncedSearch }); 46 43 47 - const libraries = useGetLibrariesForCard({ id: props.cardId }); 48 - const isInUserLibrary = libraries.data.users.some((u) => u.id === user?.id); 44 + const addCardToLibrary = useAddCardToLibrary(); 49 45 50 - const addCardToLibrary = useAddCardToLibrary(); 51 - const card = useCard({ id: props.cardId }); 46 + const cardStaus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 52 47 const { data, error } = useMyCollections(); 53 48 const [selectedCollections, setSelectedCollections] = useState< 54 49 SelectableCollectionItem[] ··· 73 68 data?.pages.flatMap((page) => page.collections ?? []) ?? []; 74 69 75 70 const collectionsWithCard = allCollections.filter((c) => 76 - card.data?.collections.some((col) => col.id === c.id), 71 + cardStaus.data.collections?.some((col) => col.id === c.id), 77 72 ); 78 73 79 74 const collectionsWithoutCard = allCollections.filter( 80 75 (c) => !collectionsWithCard.some((col) => col.id === c.id), 81 76 ); 77 + 78 + const isInUserLibrary = collectionsWithCard.length > 0; 82 79 83 80 const hasCollections = allCollections.length > 0; 84 81 const hasSelectedCollections = selectedCollections.length > 0; ··· 88 85 89 86 addCardToLibrary.mutate( 90 87 { 91 - cardId: props.cardId, 88 + url: props.cardContent.url, 92 89 collectionIds: selectedCollections.map((c) => c.id), 93 90 }, 94 91 { ··· 278 275 onClick={handleAddCard} 279 276 // disabled when: 280 277 // user already has the card in a collection (and therefore in library) 281 - // and when it's already in library and no new collection is selected yet 282 - disabled={ 283 - isLoadingUser || 284 - (isInUserLibrary && selectedCollections.length === 0) 285 - } 278 + // and no new collection is selected yet 279 + disabled={isInUserLibrary && selectedCollections.length === 0} 286 280 loading={addCardToLibrary.isPending} 287 281 > 288 282 Add
+20 -13
src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx
··· 11 11 Anchor, 12 12 } from '@mantine/core'; 13 13 import Link from 'next/link'; 14 - import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; 14 + import { 15 + GetUrlStatusForMyLibraryResponse, 16 + UrlCardView, 17 + } from '@/api-client/types'; 15 18 import { BiCollection } from 'react-icons/bi'; 16 19 import { LuLibrary } from 'react-icons/lu'; 17 20 import { getDomain } from '@/lib/utils/link'; 18 21 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 19 22 import { getRecordKey } from '@/lib/utils/atproto'; 23 + import { Fragment } from 'react'; 20 24 21 25 interface Props { 22 26 cardId: string; 23 27 cardContent: UrlCardView['cardContent']; 24 - collectionsWithCard: GetCollectionsResponse['collections']; 28 + collectionsWithCard: GetUrlStatusForMyLibraryResponse['collections']; 25 29 isInLibrary: boolean; 26 30 } 27 31 ··· 78 82 In Library 79 83 </Button> 80 84 )} 81 - {props.collectionsWithCard.length > 0 && ( 85 + {props.collectionsWithCard && props.collectionsWithCard.length > 0 && ( 82 86 <Menu shadow="sm"> 83 87 <Menu.Target> 84 88 <Button ··· 93 97 <Menu.Dropdown maw={380}> 94 98 <ScrollArea.Autosize mah={150} type="auto"> 95 99 {props.collectionsWithCard.map((c) => ( 96 - <Menu.Item 97 - key={c.id} 98 - component={Link} 99 - href={`/profile/${c.createdBy.handle}/collections/${getRecordKey(c.uri!)}`} 100 - target="_blank" 101 - c="blue" 102 - fw={600} 103 - > 104 - {c.name} 105 - </Menu.Item> 100 + <Fragment key={c.id}> 101 + {c.uri && ( 102 + <Menu.Item 103 + component={Link} 104 + href={`/profile/${profile.handle}/collections/${getRecordKey(c.uri)}`} 105 + target="_blank" 106 + c="blue" 107 + fw={600} 108 + > 109 + {c.name} 110 + </Menu.Item> 111 + )} 112 + </Fragment> 106 113 ))} 107 114 </ScrollArea.Autosize> 108 115 </Menu.Dropdown>
+9 -5
src/webapp/features/cards/lib/mutations/useAddCardToLibrary.tsx
··· 12 12 13 13 const mutation = useMutation({ 14 14 mutationFn: ({ 15 - cardId, 15 + url, 16 + note, 16 17 collectionIds, 17 18 }: { 18 - cardId: string; 19 + url: string; 20 + note?: string; 19 21 collectionIds: string[]; 20 22 }) => { 21 - return apiClient.addCardToLibrary({ cardId, collectionIds }); 23 + return apiClient.addUrlToLibrary({ url, note, collectionIds }); 22 24 }, 23 25 24 - onSuccess: (_data, variables) => { 25 - queryClient.invalidateQueries({ queryKey: ['card', variables.cardId] }); 26 + onSuccess: (data, variables) => { 27 + queryClient.invalidateQueries({ queryKey: ['card', data.urlCardId] }); 28 + queryClient.invalidateQueries({ queryKey: ['card', data.noteCardId] }); 29 + queryClient.invalidateQueries({ queryKey: ['card', variables.url] }); 26 30 queryClient.invalidateQueries({ queryKey: ['my cards'] }); 27 31 queryClient.invalidateQueries({ queryKey: ['home'] }); 28 32 queryClient.invalidateQueries({ queryKey: ['collections'] });
+1 -1
src/webapp/features/cards/lib/queries/useGetCard.tsx
··· 6 6 id: string; 7 7 } 8 8 9 - export default function useCard(props: Props) { 9 + export default function useGetCard(props: Props) { 10 10 const apiClient = new ApiClient( 11 11 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 12 createClientTokenManager(),
+21
src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx
··· 1 + import { ApiClient } from '@/api-client/ApiClient'; 2 + import { createClientTokenManager } from '@/services/auth'; 3 + import { useSuspenseQuery } from '@tanstack/react-query'; 4 + 5 + interface Props { 6 + url: string; 7 + } 8 + 9 + export default function useGetCardFromMyLibrary(props: Props) { 10 + const apiClient = new ApiClient( 11 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 + createClientTokenManager(), 13 + ); 14 + 15 + const cardStatus = useSuspenseQuery({ 16 + queryKey: ['card from my library', props.url], 17 + queryFn: () => apiClient.getUrlStatusForMyLibrary({ url: props.url }), 18 + }); 19 + 20 + return cardStatus; 21 + }
-279
src/webapp/features/collections/components/addToCollectionModal/AddToCollectionModal.tsx
··· 1 - import { UrlCardView } from '@/api-client/types'; 2 - import useCollectionSearch from '../../lib/queries/useCollectionSearch'; 3 - import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 4 - import { 5 - Group, 6 - Modal, 7 - Stack, 8 - Text, 9 - TextInput, 10 - CloseButton, 11 - Tabs, 12 - ScrollArea, 13 - Button, 14 - Loader, 15 - Alert, 16 - } from '@mantine/core'; 17 - import { useDebouncedValue } from '@mantine/hooks'; 18 - import { Fragment, useState } from 'react'; 19 - import { IoSearch } from 'react-icons/io5'; 20 - import { BiPlus } from 'react-icons/bi'; 21 - import useMyCollections from '../../lib/queries/useMyCollections'; 22 - import useCard from '@/features/cards/lib/queries/useGetCard'; 23 - import useAddCardToCollection from '@/features/collections/lib/mutations/useAddCardToCollection'; 24 - import { notifications } from '@mantine/notifications'; 25 - import CollectionSelectorError from '../collectionSelector/Error.CollectionSelector'; 26 - import CardToBeAddedPreview from './CardToBeAddedPreview'; 27 - import CollectionSelectorItemList from '../collectionSelectorItemList/CollectionSelectorItemList'; 28 - import CreateCollectionDrawer from '../createCollectionDrawer/CreateCollectionDrawer'; 29 - 30 - interface Props { 31 - isOpen: boolean; 32 - onClose: () => void; 33 - cardContent: UrlCardView['cardContent']; 34 - cardId: string; 35 - } 36 - 37 - export default function AddToCollectionModal(props: Props) { 38 - const [isDrawerOpen, setIsDrawerOpen] = useState(false); 39 - 40 - const [search, setSearch] = useState<string>(''); 41 - const [debouncedSearch] = useDebouncedValue(search, 200); 42 - const searchedCollections = useCollectionSearch({ query: debouncedSearch }); 43 - 44 - const addCardToCollection = useAddCardToCollection(); 45 - const card = useCard({ id: props.cardId }); 46 - const { data, error } = useMyCollections(); 47 - const [selectedCollections, setSelectedCollections] = useState< 48 - SelectableCollectionItem[] 49 - >([]); 50 - 51 - const handleCollectionChange = ( 52 - checked: boolean, 53 - item: SelectableCollectionItem, 54 - ) => { 55 - if (checked) { 56 - if (!selectedCollections.some((col) => col.id === item.id)) { 57 - setSelectedCollections([...selectedCollections, item]); 58 - } 59 - } else { 60 - setSelectedCollections( 61 - selectedCollections.filter((col) => col.id !== item.id), 62 - ); 63 - } 64 - }; 65 - 66 - const allCollections = 67 - data?.pages.flatMap((page) => page.collections ?? []) ?? []; 68 - 69 - const collectionsWithCard = allCollections.filter((c) => 70 - card.data?.collections.some((col) => col.id === c.id), 71 - ); 72 - 73 - const collectionsWithoutCard = allCollections.filter( 74 - (c) => !collectionsWithCard.some((col) => col.id === c.id), 75 - ); 76 - 77 - const hasCollections = allCollections.length > 0; 78 - const hasSelectedCollections = selectedCollections.length > 0; 79 - 80 - const handleAddCardToCollection = (e: React.FormEvent) => { 81 - e.preventDefault(); 82 - 83 - addCardToCollection.mutate( 84 - { 85 - cardId: props.cardId, 86 - collectionIds: selectedCollections.map((c) => c.id), 87 - }, 88 - { 89 - onSuccess: () => { 90 - setSelectedCollections([]); 91 - props.onClose(); 92 - }, 93 - onError: () => { 94 - notifications.show({ 95 - message: 'Could not add card.', 96 - }); 97 - }, 98 - onSettled: () => { 99 - setSelectedCollections([]); 100 - props.onClose(); 101 - }, 102 - }, 103 - ); 104 - }; 105 - 106 - if (error) { 107 - return <CollectionSelectorError />; 108 - } 109 - 110 - return ( 111 - <Modal 112 - opened={props.isOpen} 113 - onClose={props.onClose} 114 - title="Add to Collections" 115 - overlayProps={DEFAULT_OVERLAY_PROPS} 116 - centered 117 - > 118 - <Stack gap={'xl'}> 119 - <CardToBeAddedPreview 120 - cardContent={props.cardContent} 121 - collectionsWithCard={collectionsWithCard} 122 - /> 123 - 124 - <Stack gap={'md'}> 125 - <TextInput 126 - placeholder="Search for collections" 127 - value={search} 128 - onChange={(e) => { 129 - setSearch(e.currentTarget.value); 130 - }} 131 - size="md" 132 - variant="filled" 133 - id="search" 134 - leftSection={<IoSearch size={22} />} 135 - rightSection={ 136 - <CloseButton 137 - aria-label="Clear input" 138 - onClick={() => setSearch('')} 139 - style={{ display: search ? undefined : 'none' }} 140 - /> 141 - } 142 - /> 143 - <Stack gap={'xl'}> 144 - <Tabs defaultValue={'collections'}> 145 - <Tabs.List grow> 146 - <Tabs.Tab value="collections">Collections</Tabs.Tab> 147 - <Tabs.Tab value="selected"> 148 - Selected ({selectedCollections.length}) 149 - </Tabs.Tab> 150 - </Tabs.List> 151 - 152 - <Tabs.Panel value="collections" my="xs" w="100%"> 153 - <ScrollArea.Autosize mah={200} type="auto"> 154 - <Stack gap="xs"> 155 - {search ? ( 156 - <Fragment> 157 - <Button 158 - variant="light" 159 - size="md" 160 - color="grape" 161 - radius="lg" 162 - leftSection={<BiPlus size={22} />} 163 - onClick={() => setIsDrawerOpen(true)} 164 - > 165 - Create new collection "{search}" 166 - </Button> 167 - 168 - {searchedCollections.isPending && ( 169 - <Stack align="center"> 170 - <Text fw={500} c="gray"> 171 - Searching collections... 172 - </Text> 173 - <Loader color="gray" /> 174 - </Stack> 175 - )} 176 - 177 - {searchedCollections.data && 178 - (searchedCollections.data.collections.length === 0 ? ( 179 - <Alert 180 - color="gray" 181 - title={`No results found for "${search}"`} 182 - /> 183 - ) : ( 184 - <CollectionSelectorItemList 185 - collections={searchedCollections.data.collections} 186 - collectionsWithCard={collectionsWithCard} 187 - selectedCollections={selectedCollections} 188 - onChange={handleCollectionChange} 189 - /> 190 - ))} 191 - </Fragment> 192 - ) : hasCollections ? ( 193 - <CollectionSelectorItemList 194 - collections={collectionsWithoutCard} 195 - selectedCollections={selectedCollections} 196 - onChange={handleCollectionChange} 197 - /> 198 - ) : ( 199 - <Stack align="center" gap="xs"> 200 - <Text fz="lg" fw={600} c="gray"> 201 - No collections 202 - </Text> 203 - <Button 204 - onClick={() => setIsDrawerOpen(true)} 205 - variant="light" 206 - color="gray" 207 - rightSection={<BiPlus size={22} />} 208 - > 209 - Create a collection 210 - </Button> 211 - </Stack> 212 - )} 213 - </Stack> 214 - </ScrollArea.Autosize> 215 - </Tabs.Panel> 216 - 217 - <Tabs.Panel value="selected" my="xs"> 218 - <ScrollArea.Autosize mah={200} type="auto"> 219 - <Stack gap="xs"> 220 - {hasSelectedCollections ? ( 221 - <CollectionSelectorItemList 222 - collections={selectedCollections} 223 - selectedCollections={selectedCollections} 224 - onChange={handleCollectionChange} 225 - /> 226 - ) : ( 227 - <Alert color="gray" title="No collections selected" /> 228 - )} 229 - </Stack> 230 - </ScrollArea.Autosize> 231 - </Tabs.Panel> 232 - </Tabs> 233 - 234 - <Group justify="space-between" gap="xs" grow> 235 - <Button 236 - variant="light" 237 - color="gray" 238 - size="md" 239 - onClick={() => { 240 - setSelectedCollections([]); 241 - props.onClose(); 242 - }} 243 - > 244 - Cancel 245 - </Button> 246 - {hasSelectedCollections && ( 247 - <Button 248 - variant="light" 249 - color="grape" 250 - size="md" 251 - onClick={() => setSelectedCollections([])} 252 - > 253 - Clear 254 - </Button> 255 - )} 256 - <Button 257 - size="md" 258 - onClick={handleAddCardToCollection} 259 - disabled={selectedCollections.length === 0} 260 - loading={addCardToCollection.isPending} 261 - > 262 - Save 263 - </Button> 264 - </Group> 265 - </Stack> 266 - </Stack> 267 - </Stack> 268 - <CreateCollectionDrawer 269 - key={search} 270 - isOpen={isDrawerOpen} 271 - onClose={() => setIsDrawerOpen(false)} 272 - initialName={search} 273 - onCreate={(newCollection) => { 274 - setSelectedCollections([...selectedCollections, newCollection]); 275 - }} 276 - /> 277 - </Modal> 278 - ); 279 - }
-93
src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx
··· 1 - import { 2 - AspectRatio, 3 - Group, 4 - Stack, 5 - Image, 6 - Text, 7 - Card, 8 - Menu, 9 - Button, 10 - ScrollArea, 11 - Anchor, 12 - } from '@mantine/core'; 13 - import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; 14 - import Link from 'next/link'; 15 - import { getDomain } from '@/lib/utils/link'; 16 - import { BiSolidChevronDownCircle } from 'react-icons/bi'; 17 - 18 - interface Props { 19 - cardContent: UrlCardView['cardContent']; 20 - collectionsWithCard: GetCollectionsResponse['collections']; 21 - } 22 - 23 - export default function CardToBeAddedPreview(props: Props) { 24 - const domain = getDomain(props.cardContent.url); 25 - 26 - return ( 27 - <Stack gap={'md'}> 28 - <Card withBorder p={'xs'} radius={'lg'}> 29 - <Stack> 30 - <Group gap={'sm'}> 31 - {props.cardContent.thumbnailUrl && ( 32 - <AspectRatio ratio={1 / 1} flex={0.1}> 33 - <Image 34 - src={props.cardContent.thumbnailUrl} 35 - alt={`${props.cardContent.url} social preview image`} 36 - radius={'md'} 37 - w={50} 38 - h={50} 39 - /> 40 - </AspectRatio> 41 - )} 42 - <Stack gap={0} flex={0.9}> 43 - <Anchor 44 - component={Link} 45 - href={props.cardContent.url} 46 - target="_blank" 47 - c={'gray'} 48 - lineClamp={1} 49 - > 50 - {domain} 51 - </Anchor> 52 - {props.cardContent.title && ( 53 - <Text fw={500} lineClamp={1}> 54 - {props.cardContent.title} 55 - </Text> 56 - )} 57 - </Stack> 58 - </Group> 59 - {props.collectionsWithCard.length > 0 && ( 60 - <Menu shadow="sm"> 61 - <Menu.Target> 62 - <Button 63 - variant="light" 64 - color="grape" 65 - rightSection={<BiSolidChevronDownCircle />} 66 - > 67 - Already in {props.collectionsWithCard.length} collection 68 - {props.collectionsWithCard.length !== 1 && 's'} 69 - </Button> 70 - </Menu.Target> 71 - <Menu.Dropdown maw={380}> 72 - <ScrollArea.Autosize mah={150} type="auto"> 73 - {props.collectionsWithCard.map((c) => ( 74 - <Menu.Item 75 - key={c.id} 76 - component={Link} 77 - href={`/profile/${c.createdBy.handle}/collections/${c.id}`} 78 - target="_blank" 79 - c="blue" 80 - fw={600} 81 - > 82 - {c.name} 83 - </Menu.Item> 84 - ))} 85 - </ScrollArea.Autosize> 86 - </Menu.Dropdown> 87 - </Menu> 88 - )} 89 - </Stack> 90 - </Card> 91 - </Stack> 92 - ); 93 - }
+17 -11
src/webapp/hooks/useUrlMetadata.ts
··· 1 1 import { useState, useEffect, useCallback } from 'react'; 2 2 import { ApiClient } from '@/api-client/ApiClient'; 3 3 import type { UrlMetadata } from '@/components/UrlMetadataDisplay'; 4 - import type { UrlCardView } from '@/api-client/types'; 4 + import type { 5 + GetUrlStatusForMyLibraryResponse, 6 + UrlCardView, 7 + } from '@/api-client/types'; 5 8 6 9 interface UseUrlMetadataProps { 7 10 apiClient: ApiClient; ··· 15 18 autoFetch = true, 16 19 }: UseUrlMetadataProps) { 17 20 const [metadata, setMetadata] = useState<UrlMetadata | null>(null); 18 - const [existingCard, setExistingCard] = useState<UrlCardView | null>(null); 21 + const [existingCardCollections, setExistingCardCollections] = useState< 22 + GetUrlStatusForMyLibraryResponse['collections'] | null 23 + >(null); 19 24 const [loading, setLoading] = useState(false); 20 25 const [error, setError] = useState<string | null>(null); 21 26 ··· 33 38 34 39 setLoading(true); 35 40 setError(null); 36 - setExistingCard(null); 41 + setExistingCardCollections(null); 37 42 38 43 try { 39 44 const response = await apiClient.getUrlMetadata(targetUrl); 40 45 setMetadata(response.metadata); 41 46 42 - // If there's an existing card, fetch its details including collections 43 - if (response.existingCardId) { 47 + const existingCard = await apiClient.getUrlStatusForMyLibrary({ 48 + url: targetUrl, 49 + }); 50 + 51 + // If there's an existing card, fetch its collections 52 + if (existingCard.cardId) { 44 53 try { 45 - const cardResponse = await apiClient.getUrlCardView( 46 - response.existingCardId, 47 - ); 48 - setExistingCard(cardResponse); 54 + setExistingCardCollections(existingCard.collections); 49 55 } catch (cardErr: any) { 50 56 console.error('Failed to fetch existing card details:', cardErr); 51 57 // Don't set error here as the metadata fetch was successful ··· 55 61 console.error('Failed to fetch URL metadata:', err); 56 62 setError('Failed to load page information'); 57 63 setMetadata(null); 58 - setExistingCard(null); 64 + setExistingCardCollections(null); 59 65 } finally { 60 66 setLoading(false); 61 67 } ··· 78 84 79 85 return { 80 86 metadata, 81 - existingCard, 87 + existingCardCollections, 82 88 loading, 83 89 error, 84 90 fetchMetadata,