diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 88edd04e..e76b2083 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -23,13 +23,14 @@ export interface CardInLibraryLink { } interface CardProps { + curatorId: CuratorId; type: CardType; content: CardContent; url?: URL; parentCardId?: CardId; // For NOTE and HIGHLIGHT cards that reference other cards libraryMemberships: CardInLibraryLink[]; // Set of users who have this card in their library libraryCount: number; // Cached count of library memberships - originalPublishedRecordId?: PublishedRecordId; // The first published record ID for this card + publishedRecordId?: PublishedRecordId; // The first published record ID for this card createdAt: Date; updatedAt: Date; } @@ -75,8 +76,8 @@ export class Card extends AggregateRoot { return this.props.libraryCount; } - get originalPublishedRecordId(): PublishedRecordId | undefined { - return this.props.originalPublishedRecordId; + get publishedRecordId(): PublishedRecordId | undefined { + return this.props.publishedRecordId; } // Type-specific convenience getters @@ -245,9 +246,8 @@ export class Card extends AggregateRoot { membership.publishedRecordId = publishedRecordId; - // Set the original published record ID if it hasn't been set yet - if (!this.props.originalPublishedRecordId) { - this.props.originalPublishedRecordId = publishedRecordId; + if (!this.props.publishedRecordId) { + this.props.publishedRecordId = publishedRecordId; } this.props.updatedAt = new Date(); -- 2.51.2 From b993047b561ca287b47ddbcffc671d6812065afc Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 12:36:31 -0700 Subject: [PATCH 02/76] include a markAsPublished method on card as well as add to curators library by default in static factory method --- src/modules/cards/domain/Card.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index e76b2083..ff0520c5 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -126,7 +126,9 @@ export class Card extends AggregateRoot { updatedAt: props.updatedAt || now, }; - return ok(new Card(cardProps, id)); + const card = new Card(cardProps, id); + card.addToLibrary(props.curatorId); // Add to creator's library by default + return ok(card); } private static validateCardRelationships( @@ -233,6 +235,12 @@ export class Card extends AggregateRoot { ); } + public markAsPublished(publishedRecordId: PublishedRecordId): void { + this.props.publishedRecordId = publishedRecordId; + this.props.updatedAt = new Date(); + this.markCardInLibraryAsPublished(this.props.curatorId, publishedRecordId); + } + public markCardInLibraryAsPublished( userId: CuratorId, publishedRecordId: PublishedRecordId, -- 2.51.2 From 6014e629552d675fd937242d9e232608daa5d00e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 12:37:59 -0700 Subject: [PATCH 03/76] test: add comprehensive tests for Card aggregate library creation Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/tests/domain/Card.test.ts | 119 ++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/modules/cards/tests/domain/Card.test.ts diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts new file mode 100644 index 00000000..efee1272 --- /dev/null +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -0,0 +1,119 @@ +import { Card } from '../../domain/Card'; +import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; +import { CardContent } from '../../domain/value-objects/CardContent'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { URL } from '../../domain/value-objects/URL'; +import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; + +describe('Card', () => { + describe('create', () => { + it('should automatically add card to curator library when created', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test123').unwrap(); + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); + const url = URL.create('https://example.com').unwrap(); + const cardContent = CardContent.createUrlContent( + url, + UrlMetadata.create({ + url: url.toString(), + title: 'Test Title', + description: 'Test Description', + }).unwrap(), + ).unwrap(); + + // Act + const result = Card.create({ + curatorId, + type: cardType, + content: cardContent, + url, + }); + + // Assert + expect(result.isOk()).toBe(true); + const card = result.unwrap(); + + // Verify card is in curator's library + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.libraryMembershipCount).toBe(1); + expect(card.libraryCount).toBe(1); + + // Verify library membership details + const libraryInfo = card.getLibraryInfo(curatorId); + expect(libraryInfo).toBeDefined(); + expect(libraryInfo!.curatorId.equals(curatorId)).toBe(true); + expect(libraryInfo!.addedAt).toBeInstanceOf(Date); + expect(libraryInfo!.publishedRecordId).toBeUndefined(); + }); + + it('should create note card and add to curator library', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test456').unwrap(); + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); + const cardContent = CardContent.createNoteContent( + 'This is a test note', + curatorId, + ).unwrap(); + + // Act + const result = Card.create({ + curatorId, + type: cardType, + content: cardContent, + }); + + // Assert + expect(result.isOk()).toBe(true); + const card = result.unwrap(); + + // Verify card is in curator's library + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.libraryMembershipCount).toBe(1); + expect(card.libraryCount).toBe(1); + }); + + it('should respect provided library memberships and count', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test789').unwrap(); + const otherUserId = CuratorId.create('did:plc:other123').unwrap(); + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); + const url = URL.create('https://example.com').unwrap(); + const cardContent = CardContent.createUrlContent( + url, + UrlMetadata.create({ + url: url.toString(), + title: 'Test Title', + description: 'Test Description', + }).unwrap(), + ).unwrap(); + + const existingMemberships = [ + { + curatorId: otherUserId, + addedAt: new Date('2023-01-01'), + publishedRecordId: undefined, + }, + ]; + + // Act + const result = Card.create({ + curatorId, + type: cardType, + content: cardContent, + url, + libraryMemberships: existingMemberships, + libraryCount: 1, + }); + + // Assert + expect(result.isOk()).toBe(true); + const card = result.unwrap(); + + // Should have both the existing membership and the new curator membership + expect(card.libraryMembershipCount).toBe(2); + expect(card.libraryCount).toBe(2); + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.isInLibrary(otherUserId)).toBe(true); + }); + }); +}); -- 2.51.2 From 7506ce7a0093a3aa9d47a73cc6aea2a97a5ed104 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 12:45:14 -0700 Subject: [PATCH 04/76] feat: enforce URL cards to be in only one library Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/Card.ts | 12 +- src/modules/cards/tests/domain/Card.test.ts | 128 +++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index ff0520c5..9e2beb93 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -150,8 +150,13 @@ export class Card extends AggregateRoot { return err(new CardValidationError('URL cards must have a url property')); } - // Validate libraryCount matches libraryMemberships length when both are provided + // URL cards can only be in one library const libraryMemberships = props.libraryMemberships || []; + if (props.type.value === CardTypeEnum.URL && libraryMemberships.length > 1) { + return err(new CardValidationError('URL cards can only be in one library')); + } + + // Validate libraryCount matches libraryMemberships length when both are provided if ( props.libraryCount !== undefined && props.libraryCount !== libraryMemberships.length @@ -190,6 +195,11 @@ export class Card extends AggregateRoot { return err(new CardValidationError("Card is already in user's library")); } + // URL cards can only be in one library + if (this.isUrlCard && this.props.libraryMemberships.length > 0) { + return err(new CardValidationError("URL cards can only be in one library")); + } + this.props.libraryMemberships.push({ curatorId: userId, addedAt: new Date(), diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index efee1272..4eba7ca2 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -72,7 +72,7 @@ describe('Card', () => { expect(card.libraryCount).toBe(1); }); - it('should respect provided library memberships and count', () => { + it('should fail to create URL card with multiple library memberships', () => { // Arrange const curatorId = CuratorId.create('did:plc:test789').unwrap(); const otherUserId = CuratorId.create('did:plc:other123').unwrap(); @@ -105,6 +105,41 @@ describe('Card', () => { libraryCount: 1, }); + // Assert + if (result.isOk()) { + throw new Error('Expected creation to fail but it succeeded'); + } + expect(result.isErr()).toBe(true); + expect(result.error.message).toBe('URL cards can only be in one library'); + }); + + it('should allow NOTE cards to have multiple library memberships', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test789').unwrap(); + const otherUserId = CuratorId.create('did:plc:other123').unwrap(); + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); + const cardContent = CardContent.createNoteContent( + 'This is a test note', + curatorId, + ).unwrap(); + + const existingMemberships = [ + { + curatorId: otherUserId, + addedAt: new Date('2023-01-01'), + publishedRecordId: undefined, + }, + ]; + + // Act + const result = Card.create({ + curatorId, + type: cardType, + content: cardContent, + libraryMemberships: existingMemberships, + libraryCount: 1, + }); + // Assert expect(result.isOk()).toBe(true); const card = result.unwrap(); @@ -116,4 +151,95 @@ describe('Card', () => { expect(card.isInLibrary(otherUserId)).toBe(true); }); }); + + describe('addToLibrary', () => { + it('should prevent adding URL card to multiple libraries', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test123').unwrap(); + const otherUserId = CuratorId.create('did:plc:other456').unwrap(); + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); + const url = URL.create('https://example.com').unwrap(); + const cardContent = CardContent.createUrlContent( + url, + UrlMetadata.create({ + url: url.toString(), + title: 'Test Title', + description: 'Test Description', + }).unwrap(), + ).unwrap(); + + const card = Card.create({ + curatorId, + type: cardType, + content: cardContent, + url, + }).unwrap(); + + // Act - try to add to another user's library + const result = card.addToLibrary(otherUserId); + + // Assert + if (result.isOk()) { + throw new Error('Expected creation to fail but it succeeded'); + } + expect(result.isErr()).toBe(true); + expect(result.error.message).toBe('URL cards can only be in one library'); + expect(card.libraryMembershipCount).toBe(1); + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.isInLibrary(otherUserId)).toBe(false); + }); + + it('should allow adding NOTE card to multiple libraries', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test123').unwrap(); + const otherUserId = CuratorId.create('did:plc:other456').unwrap(); + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); + const cardContent = CardContent.createNoteContent( + 'This is a test note', + curatorId, + ).unwrap(); + + const card = Card.create({ + curatorId, + type: cardType, + content: cardContent, + }).unwrap(); + + // Act - add to another user's library + const result = card.addToLibrary(otherUserId); + + // Assert + expect(result.isOk()).toBe(true); + expect(card.libraryMembershipCount).toBe(2); + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.isInLibrary(otherUserId)).toBe(true); + }); + + it('should prevent adding same user twice to any card type', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test123').unwrap(); + const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); + const cardContent = CardContent.createNoteContent( + 'This is a test note', + curatorId, + ).unwrap(); + + const card = Card.create({ + curatorId, + type: cardType, + content: cardContent, + }).unwrap(); + + // Act - try to add same user again + const result = card.addToLibrary(curatorId); + + // Assert + if (result.isOk()) { + throw new Error('Expected creation to fail but it succeeded'); + } + expect(result.isErr()).toBe(true); + expect(result.error.message).toBe("Card is already in user's library"); + expect(card.libraryMembershipCount).toBe(1); + }); + }); }); -- 2.51.2 From e8852d708a352008a9c8c661dbcca7e1cc470ceb Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 12:52:07 -0700 Subject: [PATCH 05/76] fix: prevent duplicate library memberships when creating URL card Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/Card.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 9e2beb93..22c4068f 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -127,7 +127,15 @@ export class Card extends AggregateRoot { }; const card = new Card(cardProps, id); - card.addToLibrary(props.curatorId); // Add to creator's library by default + + // Add to creator's library by default only if not already present + if (!libraryMemberships.some(membership => membership.curatorId.equals(props.curatorId))) { + const addResult = card.addToLibrary(props.curatorId); + if (addResult.isErr()) { + return err(addResult.error); + } + } + return ok(card); } -- 2.51.2 From 8236f63548f436661d38362d573601d739512574 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 12:55:03 -0700 Subject: [PATCH 06/76] update card factory --- src/modules/cards/domain/CardFactory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/cards/domain/CardFactory.ts b/src/modules/cards/domain/CardFactory.ts index 9462e4fb..092d05ac 100644 --- a/src/modules/cards/domain/CardFactory.ts +++ b/src/modules/cards/domain/CardFactory.ts @@ -100,6 +100,7 @@ export class CardFactory { // Create the card return Card.create({ + curatorId, type: cardType, content, url, -- 2.51.2 From f84a021e77ed58846277678a6282d2236ca24e88 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 15:30:02 -0700 Subject: [PATCH 07/76] refactor: remove automatic card library addition and update tests Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/Card.ts | 9 --- src/modules/cards/tests/domain/Card.test.ts | 79 ++++++++++++++------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 22c4068f..ff98219c 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -127,15 +127,6 @@ export class Card extends AggregateRoot { }; const card = new Card(cardProps, id); - - // Add to creator's library by default only if not already present - if (!libraryMemberships.some(membership => membership.curatorId.equals(props.curatorId))) { - const addResult = card.addToLibrary(props.curatorId); - if (addResult.isErr()) { - return err(addResult.error); - } - } - return ok(card); } diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index 4eba7ca2..dbf42a31 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -7,7 +7,7 @@ import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; describe('Card', () => { describe('create', () => { - it('should automatically add card to curator library when created', () => { + it('should create URL card without automatically adding to library', () => { // Arrange const curatorId = CuratorId.create('did:plc:test123').unwrap(); const cardType = CardType.create(CardTypeEnum.URL).unwrap(); @@ -33,20 +33,13 @@ describe('Card', () => { expect(result.isOk()).toBe(true); const card = result.unwrap(); - // Verify card is in curator's library - expect(card.isInLibrary(curatorId)).toBe(true); - expect(card.libraryMembershipCount).toBe(1); - expect(card.libraryCount).toBe(1); - - // Verify library membership details - const libraryInfo = card.getLibraryInfo(curatorId); - expect(libraryInfo).toBeDefined(); - expect(libraryInfo!.curatorId.equals(curatorId)).toBe(true); - expect(libraryInfo!.addedAt).toBeInstanceOf(Date); - expect(libraryInfo!.publishedRecordId).toBeUndefined(); + // Verify card is NOT automatically in curator's library + expect(card.isInLibrary(curatorId)).toBe(false); + expect(card.libraryMembershipCount).toBe(0); + expect(card.libraryCount).toBe(0); }); - it('should create note card and add to curator library', () => { + it('should create note card without automatically adding to library', () => { // Arrange const curatorId = CuratorId.create('did:plc:test456').unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); @@ -66,10 +59,10 @@ describe('Card', () => { expect(result.isOk()).toBe(true); const card = result.unwrap(); - // Verify card is in curator's library - expect(card.isInLibrary(curatorId)).toBe(true); - expect(card.libraryMembershipCount).toBe(1); - expect(card.libraryCount).toBe(1); + // Verify card is NOT automatically in curator's library + expect(card.isInLibrary(curatorId)).toBe(false); + expect(card.libraryMembershipCount).toBe(0); + expect(card.libraryCount).toBe(0); }); it('should fail to create URL card with multiple library memberships', () => { @@ -144,15 +137,45 @@ describe('Card', () => { expect(result.isOk()).toBe(true); const card = result.unwrap(); - // Should have both the existing membership and the new curator membership - expect(card.libraryMembershipCount).toBe(2); - expect(card.libraryCount).toBe(2); - expect(card.isInLibrary(curatorId)).toBe(true); + // Should have only the existing membership (no automatic curator addition) + expect(card.libraryMembershipCount).toBe(1); + expect(card.libraryCount).toBe(1); + expect(card.isInLibrary(curatorId)).toBe(false); expect(card.isInLibrary(otherUserId)).toBe(true); }); }); describe('addToLibrary', () => { + it('should allow adding URL card to first library', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test123').unwrap(); + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); + const url = URL.create('https://example.com').unwrap(); + const cardContent = CardContent.createUrlContent( + url, + UrlMetadata.create({ + url: url.toString(), + title: 'Test Title', + description: 'Test Description', + }).unwrap(), + ).unwrap(); + + const card = Card.create({ + curatorId, + type: cardType, + content: cardContent, + url, + }).unwrap(); + + // Act - add to curator's library + const result = card.addToLibrary(curatorId); + + // Assert + expect(result.isOk()).toBe(true); + expect(card.libraryMembershipCount).toBe(1); + expect(card.isInLibrary(curatorId)).toBe(true); + }); + it('should prevent adding URL card to multiple libraries', () => { // Arrange const curatorId = CuratorId.create('did:plc:test123').unwrap(); @@ -175,6 +198,9 @@ describe('Card', () => { url, }).unwrap(); + // First add to curator's library + card.addToLibrary(curatorId); + // Act - try to add to another user's library const result = card.addToLibrary(otherUserId); @@ -205,11 +231,13 @@ describe('Card', () => { content: cardContent, }).unwrap(); - // Act - add to another user's library - const result = card.addToLibrary(otherUserId); + // Act - add to both libraries + const result1 = card.addToLibrary(curatorId); + const result2 = card.addToLibrary(otherUserId); // Assert - expect(result.isOk()).toBe(true); + expect(result1.isOk()).toBe(true); + expect(result2.isOk()).toBe(true); expect(card.libraryMembershipCount).toBe(2); expect(card.isInLibrary(curatorId)).toBe(true); expect(card.isInLibrary(otherUserId)).toBe(true); @@ -230,6 +258,9 @@ describe('Card', () => { content: cardContent, }).unwrap(); + // First add to library + card.addToLibrary(curatorId); + // Act - try to add same user again const result = card.addToLibrary(curatorId); -- 2.51.2 From d908cf8039ac7a17d7d27bef04060769aec68270 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 15:34:14 -0700 Subject: [PATCH 08/76] test: update URL card library membership tests Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/tests/domain/Card.test.ts | 49 ++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index dbf42a31..62911180 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -65,7 +65,7 @@ describe('Card', () => { expect(card.libraryCount).toBe(0); }); - it('should fail to create URL card with multiple library memberships', () => { + it('should fail to create URL card with library membership different from creator', () => { // Arrange const curatorId = CuratorId.create('did:plc:test789').unwrap(); const otherUserId = CuratorId.create('did:plc:other123').unwrap(); @@ -106,6 +106,53 @@ describe('Card', () => { expect(result.error.message).toBe('URL cards can only be in one library'); }); + it('should fail to create URL card with multiple library memberships', () => { + // Arrange + const curatorId = CuratorId.create('did:plc:test789').unwrap(); + const otherUserId1 = CuratorId.create('did:plc:other123').unwrap(); + const otherUserId2 = CuratorId.create('did:plc:other456').unwrap(); + const cardType = CardType.create(CardTypeEnum.URL).unwrap(); + const url = URL.create('https://example.com').unwrap(); + const cardContent = CardContent.createUrlContent( + url, + UrlMetadata.create({ + url: url.toString(), + title: 'Test Title', + description: 'Test Description', + }).unwrap(), + ).unwrap(); + + const existingMemberships = [ + { + curatorId: otherUserId1, + addedAt: new Date('2023-01-01'), + publishedRecordId: undefined, + }, + { + curatorId: otherUserId2, + addedAt: new Date('2023-01-02'), + publishedRecordId: undefined, + }, + ]; + + // Act + const result = Card.create({ + curatorId, + type: cardType, + content: cardContent, + url, + libraryMemberships: existingMemberships, + libraryCount: 2, + }); + + // Assert + if (result.isOk()) { + throw new Error('Expected creation to fail but it succeeded'); + } + expect(result.isErr()).toBe(true); + expect(result.error.message).toBe('URL cards can only be in one library'); + }); + it('should allow NOTE cards to have multiple library memberships', () => { // Arrange const curatorId = CuratorId.create('did:plc:test789').unwrap(); -- 2.51.2 From 1943c2f4188c697cd1141463d8aafa437db59eb0 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 15:37:40 -0700 Subject: [PATCH 09/76] fix: validate URL card library membership belongs to creator Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/Card.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index ff98219c..3522f33e 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -155,6 +155,16 @@ export class Card extends AggregateRoot { return err(new CardValidationError('URL cards can only be in one library')); } + // URL cards can only have library memberships for the creator + if (props.type.value === CardTypeEnum.URL && libraryMemberships.length > 0) { + const hasNonCreatorMembership = libraryMemberships.some( + membership => !membership.curatorId.equals(props.curatorId) + ); + if (hasNonCreatorMembership) { + return err(new CardValidationError('URL cards can only be in one library')); + } + } + // Validate libraryCount matches libraryMemberships length when both are provided if ( props.libraryCount !== undefined && -- 2.51.2 From 09e1d5fd315270c067a766d13da20e2e7145eece Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 15:50:21 -0700 Subject: [PATCH 10/76] update use case and card repo interface to only look for existing url card of the current user --- .../application/useCases/commands/AddUrlToLibraryUseCase.ts | 2 +- src/modules/cards/domain/ICardRepository.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts b/src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts index 51443189..a24ae5e9 100644 --- a/src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts @@ -80,7 +80,7 @@ export class AddUrlToLibraryUseCase extends BaseUseCase< // Check if URL card already exists const existingUrlCardResult = - await this.cardRepository.findUrlCardByUrl(url); + await this.cardRepository.findUsersUrlCardByUrl(url, curatorId); if (existingUrlCardResult.isErr()) { return err( AppError.UnexpectedError.create(existingUrlCardResult.error), diff --git a/src/modules/cards/domain/ICardRepository.ts b/src/modules/cards/domain/ICardRepository.ts index 48428d23..5e77be7c 100644 --- a/src/modules/cards/domain/ICardRepository.ts +++ b/src/modules/cards/domain/ICardRepository.ts @@ -1,11 +1,15 @@ import { Result } from '../../../shared/core/Result'; import { Card } from './Card'; import { CardId } from './value-objects/CardId'; +import { CuratorId } from './value-objects/CuratorId'; import { URL } from './value-objects/URL'; export interface ICardRepository { findById(id: CardId): Promise>; save(card: Card): Promise>; delete(cardId: CardId): Promise>; - findUrlCardByUrl(url: URL): Promise>; + findUsersUrlCardByUrl( + url: URL, + curatorId: CuratorId, + ): Promise>; } -- 2.51.2 From 931e3ef920e58c8eb023707ecaff3d572b100d13 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 16:02:31 -0700 Subject: [PATCH 11/76] refactor: update InMemoryCardRepository to match new Card repository interface Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../cards/tests/utils/InMemoryCardRepository.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/modules/cards/tests/utils/InMemoryCardRepository.ts b/src/modules/cards/tests/utils/InMemoryCardRepository.ts index f216d9d7..ccf98180 100644 --- a/src/modules/cards/tests/utils/InMemoryCardRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardRepository.ts @@ -2,6 +2,7 @@ import { Result, ok, err } from '../../../../shared/core/Result'; import { ICardRepository } from '../../domain/ICardRepository'; import { Card } from '../../domain/Card'; import { CardId } from '../../domain/value-objects/CardId'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; import { URL } from '../../domain/value-objects/URL'; export class InMemoryCardRepository implements ICardRepository { @@ -13,12 +14,14 @@ export class InMemoryCardRepository implements ICardRepository { // Simple clone - in a real implementation you'd want proper deep cloning const cardResult = Card.create( { + curatorId: card.props.curatorId, type: card.type, content: card.content, parentCardId: card.parentCardId, url: card.url, - originalPublishedRecordId: card.originalPublishedRecordId, + publishedRecordId: card.publishedRecordId, libraryMemberships: card.libraryMemberships, + libraryCount: card.libraryCount, createdAt: card.createdAt, updatedAt: card.updatedAt, }, @@ -44,12 +47,16 @@ export class InMemoryCardRepository implements ICardRepository { } } - async findUrlCardByUrl(url: URL): Promise> { + async findUsersUrlCardByUrl( + url: URL, + curatorId: CuratorId, + ): Promise> { try { const card = Array.from(this.cards.values()).find( (card) => card.content.type === 'URL' && - card.content.urlContent?.url.value === url.value, + card.content.urlContent?.url.value === url.value && + card.props.curatorId.equals(curatorId), ); return ok(card ? this.clone(card) : null); } catch (error) { -- 2.51.2 From 435b06ac3992cd4a0c7977da421f9e281efd1e57 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 6 Oct 2025 16:06:31 -0700 Subject: [PATCH 12/76] test: add test cases for URL card creation scenarios Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../AddUrlToLibraryUseCase.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts b/src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts index 961692d8..917c52a9 100644 --- a/src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts @@ -188,6 +188,80 @@ describe('AddUrlToLibraryUseCase', () => { ); expect(urlCards).toHaveLength(1); // Only one URL card }); + + it('should create new URL card when another user has URL card with same URL', async () => { + const url = 'https://example.com/shared'; + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); + + // First user creates URL card + const firstRequest = { + url, + curatorId: otherCuratorId.value, + }; + + const firstResult = await useCase.execute(firstRequest); + expect(firstResult.isOk()).toBe(true); + const firstResponse = firstResult.unwrap(); + + // Second user (different curator) should create their own URL card + const secondRequest = { + url, + curatorId: curatorId.value, + }; + + const secondResult = await useCase.execute(secondRequest); + expect(secondResult.isOk()).toBe(true); + const secondResponse = secondResult.unwrap(); + + // Should have different URL card IDs + expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId); + + // Should have two separate URL cards + const savedCards = cardRepository.getAllCards(); + expect(savedCards).toHaveLength(2); + + const urlCards = savedCards.filter( + (card) => card.content.type === CardTypeEnum.URL, + ); + expect(urlCards).toHaveLength(2); // Two separate URL cards + + // Verify each card belongs to the correct curator + const firstUserCard = urlCards.find((card) => + card.props.curatorId.equals(otherCuratorId), + ); + const secondUserCard = urlCards.find((card) => + card.props.curatorId.equals(curatorId), + ); + + expect(firstUserCard).toBeDefined(); + expect(secondUserCard).toBeDefined(); + }); + + it('should create new URL card when no one has URL card with that URL yet', async () => { + const url = 'https://example.com/brand-new'; + + // Verify no cards exist initially + expect(cardRepository.getAllCards()).toHaveLength(0); + + const request = { + url, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(request); + + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + expect(response.urlCardId).toBeDefined(); + + // Verify new URL card was created + const savedCards = cardRepository.getAllCards(); + expect(savedCards).toHaveLength(1); + + const urlCard = savedCards[0]; + expect(urlCard?.content.type).toBe(CardTypeEnum.URL); + expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true); + }); }); describe('Collection handling', () => { -- 2.51.2 From 6fe3d1d1003052723715596fc91b0a28f53d907c Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:18:30 -0700 Subject: [PATCH 13/76] update use case tests --- src/modules/cards/domain/Card.ts | 26 ++++++++++++++----- .../AddCardToLibraryUseCase.test.ts | 17 +++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 3522f33e..ee5ceaf3 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -151,17 +151,29 @@ export class Card extends AggregateRoot { // URL cards can only be in one library const libraryMemberships = props.libraryMemberships || []; - if (props.type.value === CardTypeEnum.URL && libraryMemberships.length > 1) { - return err(new CardValidationError('URL cards can only be in one library')); + if ( + props.type.value === CardTypeEnum.URL && + libraryMemberships.length > 1 + ) { + return err( + new CardValidationError('URL cards can only be in one library'), + ); } // URL cards can only have library memberships for the creator - if (props.type.value === CardTypeEnum.URL && libraryMemberships.length > 0) { + if ( + props.type.value === CardTypeEnum.URL && + libraryMemberships.length > 0 + ) { const hasNonCreatorMembership = libraryMemberships.some( - membership => !membership.curatorId.equals(props.curatorId) + (membership) => !membership.curatorId.equals(props.curatorId), ); if (hasNonCreatorMembership) { - return err(new CardValidationError('URL cards can only be in one library')); + return err( + new CardValidationError( + 'URL cards can only be in the library of the creator', + ), + ); } } @@ -206,7 +218,9 @@ export class Card extends AggregateRoot { // URL cards can only be in one library if (this.isUrlCard && this.props.libraryMemberships.length > 0) { - return err(new CardValidationError("URL cards can only be in one library")); + return err( + new CardValidationError('URL cards can only be in one library'), + ); } this.props.libraryMemberships.push({ diff --git a/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts b/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts index 5f7ba64f..43a17729 100644 --- a/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts @@ -54,7 +54,7 @@ describe('AddCardToLibraryUseCase', () => { }); describe('Basic card addition to library', () => { - it('should add an existing card to library', async () => { + it('should not allow adding an existing url card to library', async () => { // Create and save a card first const card = new CardBuilder() .withCuratorId(curatorId.value) @@ -74,15 +74,12 @@ describe('AddCardToLibraryUseCase', () => { const result = await useCase.execute(request); - expect(result.isOk()).toBe(true); - const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); - - // Verify card was published to library - const publishedCards = cardPublisher.getPublishedCards(); - expect(publishedCards).toHaveLength(1); - expect(publishedCards[0]?.cardId.getStringValue()).toBe( - card.cardId.getStringValue(), + if (result.isOk()) { + throw new Error('Expected use case to fail, but it succeeded'); + } + expect(result.isErr()).toBe(true); + expect(result.error.message).toContain( + 'URL cards can only be in one library', ); }); -- 2.51.2 From f52204c86a0a1be7332ff9ac0deaa5be639a2b28 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:28:21 -0700 Subject: [PATCH 14/76] remove curator id from note content (since in card) --- src/modules/cards/domain/Card.ts | 4 ++++ src/modules/cards/domain/CardFactory.ts | 5 ++-- .../cards/domain/value-objects/CardContent.ts | 4 +--- .../value-objects/content/NoteCardContent.ts | 7 ------ src/modules/cards/tests/domain/Card.test.ts | 4 ---- .../cards/tests/utils/builders/CardBuilder.ts | 24 +++++++------------ 6 files changed, 16 insertions(+), 32 deletions(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index ee5ceaf3..6c8b5e89 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -40,6 +40,10 @@ export class Card extends AggregateRoot { return CardId.create(this._id).unwrap(); } + get curatorId(): CuratorId { + return this.props.curatorId; + } + get type(): CardType { return this.props.type; } diff --git a/src/modules/cards/domain/CardFactory.ts b/src/modules/cards/domain/CardFactory.ts index 092d05ac..9dd2d1d4 100644 --- a/src/modules/cards/domain/CardFactory.ts +++ b/src/modules/cards/domain/CardFactory.ts @@ -124,7 +124,7 @@ export class CardFactory { return this.createUrlContent(cardInput); case CardTypeEnum.NOTE: - return this.createNoteContent(cardInput, curatorId); + return this.createNoteContent(cardInput); default: return err(new CardValidationError('Invalid card type')); @@ -158,9 +158,8 @@ export class CardFactory { private static createNoteContent( input: INoteCardInput, - curatorId: CuratorId, ): Result { - return CardContent.createNoteContent(input.text, curatorId); + return CardContent.createNoteContent(input.text); } // Type guards diff --git a/src/modules/cards/domain/value-objects/CardContent.ts b/src/modules/cards/domain/value-objects/CardContent.ts index 29760ca7..1509b09f 100644 --- a/src/modules/cards/domain/value-objects/CardContent.ts +++ b/src/modules/cards/domain/value-objects/CardContent.ts @@ -59,11 +59,9 @@ export class CardContent extends ValueObject<{ content: CardContentUnion }> { public static createNoteContent( text: string, - curatorId: CuratorId, ): Result { // Use provided curatorId or create a dummy one for backward compatibility - const authorId = curatorId; - const noteContentResult = NoteCardContent.create(authorId, text); + const noteContentResult = NoteCardContent.create(text); if (noteContentResult.isErr()) { return err( new CardContentValidationError(noteContentResult.error.message), diff --git a/src/modules/cards/domain/value-objects/content/NoteCardContent.ts b/src/modules/cards/domain/value-objects/content/NoteCardContent.ts index 2b17e12a..36c595b0 100644 --- a/src/modules/cards/domain/value-objects/content/NoteCardContent.ts +++ b/src/modules/cards/domain/value-objects/content/NoteCardContent.ts @@ -13,7 +13,6 @@ export class NoteCardContentValidationError extends Error { interface NoteCardContentProps { type: CardTypeEnum.NOTE; text: string; - authorId: CuratorId; } export class NoteCardContent extends ValueObject { @@ -27,16 +26,11 @@ export class NoteCardContent extends ValueObject { return this.props.text; } - get authorId(): CuratorId { - return this.props.authorId; - } - private constructor(props: NoteCardContentProps) { super(props); } public static create( - authorId: CuratorId, text: string, ): Result { if (!text || text.trim().length === 0) { @@ -57,7 +51,6 @@ export class NoteCardContent extends ValueObject { new NoteCardContent({ type: CardTypeEnum.NOTE, text: text.trim(), - authorId, }), ); } diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index 62911180..2df68f5f 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -45,7 +45,6 @@ describe('Card', () => { const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardContent = CardContent.createNoteContent( 'This is a test note', - curatorId, ).unwrap(); // Act @@ -160,7 +159,6 @@ describe('Card', () => { const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardContent = CardContent.createNoteContent( 'This is a test note', - curatorId, ).unwrap(); const existingMemberships = [ @@ -269,7 +267,6 @@ describe('Card', () => { const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardContent = CardContent.createNoteContent( 'This is a test note', - curatorId, ).unwrap(); const card = Card.create({ @@ -296,7 +293,6 @@ describe('Card', () => { const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardContent = CardContent.createNoteContent( 'This is a test note', - curatorId, ).unwrap(); const card = Card.create({ diff --git a/src/modules/cards/tests/utils/builders/CardBuilder.ts b/src/modules/cards/tests/utils/builders/CardBuilder.ts index a6759a29..2d9fb85e 100644 --- a/src/modules/cards/tests/utils/builders/CardBuilder.ts +++ b/src/modules/cards/tests/utils/builders/CardBuilder.ts @@ -15,7 +15,7 @@ export class CardBuilder { private _content?: CardContent; private _url?: URL; private _parentCardId?: CardId; - private _originalPublishedRecordId?: PublishedRecordId; + private _publishedRecordId?: PublishedRecordId; private _createdAt?: Date; private _updatedAt?: Date; @@ -43,10 +43,8 @@ export class CardBuilder { } else if (type === CardTypeEnum.NOTE) { const curatorIdResult = CuratorId.create(this._curatorId); if (curatorIdResult.isOk()) { - const contentResult = CardContent.createNoteContent( - 'Default note text', - curatorIdResult.value, - ); + const contentResult = + CardContent.createNoteContent('Default note text'); if (contentResult.isOk()) { this._content = contentResult.value; } @@ -70,7 +68,7 @@ export class CardBuilder { uri: string; cid: string; }): CardBuilder { - this._originalPublishedRecordId = PublishedRecordId.create( + this._publishedRecordId = PublishedRecordId.create( originalPublishedRecordId, ); return this; @@ -102,10 +100,7 @@ export class CardBuilder { withNoteCard(text: string): CardBuilder { this._type = CardTypeEnum.NOTE; - const contentResult = CardContent.createNoteContent( - text, - CuratorId.create(this._curatorId).unwrap(), - ); + const contentResult = CardContent.createNoteContent(text); if (contentResult.isErr()) { throw new Error( `Failed to create note content: ${contentResult.error.message}`, @@ -138,10 +133,8 @@ export class CardBuilder { } this._content = contentResult.value; } else if (this._type === CardTypeEnum.NOTE) { - const contentResult = CardContent.createNoteContent( - 'Default note text', - curatorIdResult.value, - ); + const contentResult = + CardContent.createNoteContent('Default note text'); if (contentResult.isErr()) { return new Error( `Failed to create note content: ${contentResult.error.message}`, @@ -167,11 +160,12 @@ export class CardBuilder { const cardResult = Card.create( { + curatorId: curatorIdResult.value, type: cardTypeResult.value, content: this._content, url: this._url, parentCardId: this._parentCardId, - originalPublishedRecordId: this._originalPublishedRecordId, + publishedRecordId: this._publishedRecordId, createdAt: this._createdAt, updatedAt: this._updatedAt, }, -- 2.51.2 From dadc0f346109cf6b94c41209d96d2e663c297d68 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:30:27 -0700 Subject: [PATCH 15/76] test: update URL card library membership error message --- src/modules/cards/tests/domain/Card.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index 2df68f5f..04e41a17 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -102,7 +102,9 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe('URL cards can only be in one library'); + expect(result.error.message).toBe( + 'URL cards can only be in the library of the creator', + ); }); it('should fail to create URL card with multiple library memberships', () => { -- 2.51.2 From ea57217edfe79b2727391a21ae75c4c86a82c9b2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:30:29 -0700 Subject: [PATCH 16/76] refactor: extract error messages to constants in Card domain Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/Card.ts | 36 +++++++++++++-------- src/modules/cards/tests/domain/Card.test.ts | 10 +++--- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 6c8b5e89..48cdb654 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -9,6 +9,18 @@ import { PublishedRecordId } from './value-objects/PublishedRecordId'; import { URL } from './value-objects/URL'; import { CardAddedToLibraryEvent } from './events/CardAddedToLibraryEvent'; +export const CARD_ERROR_MESSAGES = { + CARD_TYPE_CONTENT_MISMATCH: 'Card type must match content type', + URL_CARD_CANNOT_HAVE_PARENT: 'URL cards cannot have parent cards', + URL_CARD_MUST_HAVE_URL: 'URL cards must have a url property', + URL_CARD_SINGLE_LIBRARY_ONLY: 'URL cards can only be in one library', + URL_CARD_CREATOR_LIBRARY_ONLY: 'URL cards can only be in the library of the creator', + LIBRARY_COUNT_MISMATCH: 'Library count does not match library memberships length', + CANNOT_CHANGE_CONTENT_TYPE: 'Cannot change card content to different type', + ALREADY_IN_LIBRARY: "Card is already in user's library", + NOT_IN_LIBRARY: "Card is not in user's library", +} as const; + export class CardValidationError extends Error { constructor(message: string) { super(message); @@ -111,7 +123,7 @@ export class Card extends AggregateRoot { ): Result { // Validate content type matches card type if (props.type.value !== props.content.type) { - return err(new CardValidationError('Card type must match content type')); + return err(new CardValidationError(CARD_ERROR_MESSAGES.CARD_TYPE_CONTENT_MISMATCH)); } // Validate parent/source card relationships @@ -145,12 +157,12 @@ export class Card extends AggregateRoot { ): Result { // URL cards should not have parent cards if (props.type.value === CardTypeEnum.URL && props.parentCardId) { - return err(new CardValidationError('URL cards cannot have parent cards')); + return err(new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_CANNOT_HAVE_PARENT)); } // URL cards must have a URL property if (props.type.value === CardTypeEnum.URL && !props.url) { - return err(new CardValidationError('URL cards must have a url property')); + return err(new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_MUST_HAVE_URL)); } // URL cards can only be in one library @@ -160,7 +172,7 @@ export class Card extends AggregateRoot { libraryMemberships.length > 1 ) { return err( - new CardValidationError('URL cards can only be in one library'), + new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY), ); } @@ -174,9 +186,7 @@ export class Card extends AggregateRoot { ); if (hasNonCreatorMembership) { return err( - new CardValidationError( - 'URL cards can only be in the library of the creator', - ), + new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY), ); } } @@ -188,7 +198,7 @@ export class Card extends AggregateRoot { ) { return err( new CardValidationError( - `Library count (${props.libraryCount}) does not match library memberships length (${libraryMemberships.length})`, + `${CARD_ERROR_MESSAGES.LIBRARY_COUNT_MISMATCH} (${props.libraryCount} vs ${libraryMemberships.length})`, ), ); } @@ -201,7 +211,7 @@ export class Card extends AggregateRoot { ): Result { if (this.props.type.value !== newContent.type) { return err( - new CardValidationError('Cannot change card content to different type'), + new CardValidationError(CARD_ERROR_MESSAGES.CANNOT_CHANGE_CONTENT_TYPE), ); } @@ -217,13 +227,13 @@ export class Card extends AggregateRoot { link.curatorId.equals(userId), ) ) { - return err(new CardValidationError("Card is already in user's library")); + return err(new CardValidationError(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY)); } // URL cards can only be in one library if (this.isUrlCard && this.props.libraryMemberships.length > 0) { return err( - new CardValidationError('URL cards can only be in one library'), + new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY), ); } @@ -252,7 +262,7 @@ export class Card extends AggregateRoot { link.curatorId.equals(userId), ) ) { - return err(new CardValidationError("Card is not in user's library")); + return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); } this.props.libraryMemberships = this.props.libraryMemberships.filter( @@ -286,7 +296,7 @@ export class Card extends AggregateRoot { link.curatorId.equals(userId), ); if (!membership) { - return err(new CardValidationError("Card is not in user's library")); + return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); } membership.publishedRecordId = publishedRecordId; diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index 04e41a17..3b8028a6 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -1,4 +1,4 @@ -import { Card } from '../../domain/Card'; +import { Card, CARD_ERROR_MESSAGES } from '../../domain/Card'; import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; import { CardContent } from '../../domain/value-objects/CardContent'; import { CuratorId } from '../../domain/value-objects/CuratorId'; @@ -103,7 +103,7 @@ describe('Card', () => { } expect(result.isErr()).toBe(true); expect(result.error.message).toBe( - 'URL cards can only be in the library of the creator', + CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY, ); }); @@ -151,7 +151,7 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe('URL cards can only be in one library'); + expect(result.error.message).toBe(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY); }); it('should allow NOTE cards to have multiple library memberships', () => { @@ -256,7 +256,7 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe('URL cards can only be in one library'); + expect(result.error.message).toBe(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY); expect(card.libraryMembershipCount).toBe(1); expect(card.isInLibrary(curatorId)).toBe(true); expect(card.isInLibrary(otherUserId)).toBe(false); @@ -314,7 +314,7 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe("Card is already in user's library"); + expect(result.error.message).toBe(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY); expect(card.libraryMembershipCount).toBe(1); }); }); -- 2.51.2 From c5659be7c5f1a877f7d62e403cfe9525b5f09c62 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:53:23 -0700 Subject: [PATCH 17/76] fix: add missing curatorId in Card.create() calls in test cases --- .../tests/application/GetCollectionPageUseCase.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index 2bb0a48c..9e0ab402 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -125,6 +125,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const card1Result = Card.create({ + curatorId: curatorId, type: cardType1, content: cardContent1, url: url1, @@ -157,6 +158,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const card2Result = Card.create({ + curatorId: curatorId, type: cardType2, content: cardContent2, url: url2, @@ -234,6 +236,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const cardResult = Card.create({ + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -252,10 +255,10 @@ describe('GetCollectionPageUseCase', () => { // Create a note card that references the same URL const noteCardResult = Card.create({ + curatorId: curatorId, type: CardType.create(CardTypeEnum.NOTE).unwrap(), content: CardContent.createNoteContent( 'This is my note about the article', - curatorId, ).unwrap(), parentCardId: card.cardId, url: url, @@ -353,6 +356,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const cardResult = Card.create({ + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -475,6 +479,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const alphaCardResult = Card.create({ + curatorId: curatorId, type: alphaCardType, content: alphaCardContent, url: alphaUrl, @@ -506,6 +511,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const betaCardResult = Card.create({ + curatorId: curatorId, type: betaCardType, content: betaCardContent, url: betaUrl, @@ -545,6 +551,7 @@ describe('GetCollectionPageUseCase', () => { ).unwrap(); const gammaCardResult = Card.create({ + curatorId: curatorId, type: gammaCardType, content: gammaCardContent, url: gammaUrl, @@ -823,6 +830,7 @@ describe('GetCollectionPageUseCase', () => { const cardContent = CardContent.createUrlContent(url).unwrap(); const cardResult = Card.create({ + curatorId: curatorId, type: cardType, content: cardContent, url: url, -- 2.51.2 From 7e948bb3769643e8846d05c7bc9599070e34c0b6 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 09:59:47 -0700 Subject: [PATCH 18/76] refactor: update GetCollectionPageUseCase test to reflect URL card library constraints Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetCollectionPageUseCase.test.ts | 87 +++++++------------ 1 file changed, 30 insertions(+), 57 deletions(-) diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index 9e0ab402..be21fcf3 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -465,7 +465,7 @@ describe('GetCollectionPageUseCase', () => { // Create URL cards with different properties for sorting const now = new Date(); - // Create Alpha card + // Create Alpha card (oldest created, middle updated) const alphaMetadata = UrlMetadata.create({ url: 'https://example.com/alpha', title: 'Alpha Article', @@ -487,8 +487,8 @@ describe('GetCollectionPageUseCase', () => { { curatorId: curatorId, addedAt: new Date(now.getTime() - 5000) }, ], libraryCount: 1, - createdAt: new Date(now.getTime() - 2000), - updatedAt: new Date(now.getTime() - 1000), + createdAt: new Date(now.getTime() - 3000), // oldest + updatedAt: new Date(now.getTime() - 1000), // middle }); if (alphaCardResult.isErr()) { @@ -497,7 +497,7 @@ describe('GetCollectionPageUseCase', () => { await cardRepo.save(alphaCardResult.value); - // Create Beta card + // Create Beta card (middle created, oldest updated) const betaMetadata = UrlMetadata.create({ url: 'https://example.com/beta', title: 'Beta Article', @@ -517,18 +517,10 @@ describe('GetCollectionPageUseCase', () => { url: betaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime() - 5000) }, - { - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), - addedAt: new Date(now.getTime() - 2000), - }, - { - curatorId: CuratorId.create('did:plc:thirdcurator').unwrap(), - addedAt: new Date(now.getTime() - 1000), - }, ], - libraryCount: 3, - createdAt: new Date(now.getTime() - 1000), - updatedAt: new Date(now.getTime() - 2000), + libraryCount: 1, + createdAt: new Date(now.getTime() - 2000), // middle + updatedAt: new Date(now.getTime() - 3000), // oldest }); if (betaCardResult.isErr()) { @@ -537,7 +529,7 @@ describe('GetCollectionPageUseCase', () => { await cardRepo.save(betaCardResult.value); - // Create Gamma card + // Create Gamma card (newest created, newest updated) const gammaMetadata = UrlMetadata.create({ url: 'https://example.com/gamma', title: 'Gamma Article', @@ -557,14 +549,10 @@ describe('GetCollectionPageUseCase', () => { url: gammaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime() - 3000) }, - { - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), - addedAt: new Date(now.getTime() - 1000), - }, ], - libraryCount: 2, - createdAt: new Date(now.getTime()), - updatedAt: new Date(now.getTime()), + libraryCount: 1, + createdAt: new Date(now.getTime() - 1000), // newest + updatedAt: new Date(now.getTime()), // newest }); if (gammaCardResult.isErr()) { @@ -580,10 +568,10 @@ describe('GetCollectionPageUseCase', () => { await collectionRepo.save(collection); }); - it('should sort by library count descending', async () => { + it('should sort by updated date descending', async () => { const query = { collectionId: collectionId.getStringValue(), - sortBy: CardSortField.LIBRARY_COUNT, + sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }; @@ -591,18 +579,15 @@ describe('GetCollectionPageUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.urlCards).toHaveLength(3); - expect(response.urlCards[0]?.libraryCount).toBe(3); // beta - expect(response.urlCards[1]?.libraryCount).toBe(2); // gamma - expect(response.urlCards[2]?.libraryCount).toBe(1); // alpha - expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); - expect(response.sorting.sortOrder).toBe(SortOrder.DESC); + expect(response.urlCards[0]?.cardContent.title).toBe('Gamma Article'); // newest updated + expect(response.urlCards[1]?.cardContent.title).toBe('Alpha Article'); // middle updated + expect(response.urlCards[2]?.cardContent.title).toBe('Beta Article'); // oldest updated }); - it('should sort by library count ascending', async () => { + it('should sort by created date ascending', async () => { const query = { collectionId: collectionId.getStringValue(), - sortBy: CardSortField.LIBRARY_COUNT, + sortBy: CardSortField.CREATED_AT, sortOrder: SortOrder.ASC, }; @@ -610,15 +595,15 @@ describe('GetCollectionPageUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.urlCards[0]?.libraryCount).toBe(1); // alpha - expect(response.urlCards[1]?.libraryCount).toBe(2); // gamma - expect(response.urlCards[2]?.libraryCount).toBe(3); // beta + expect(response.urlCards[0]?.cardContent.title).toBe('Alpha Article'); // oldest created + expect(response.urlCards[1]?.cardContent.title).toBe('Beta Article'); // middle created + expect(response.urlCards[2]?.cardContent.title).toBe('Gamma Article'); // newest created }); - it('should sort by updated date descending', async () => { + it('should sort by library count (all URL cards have same count)', async () => { const query = { collectionId: collectionId.getStringValue(), - sortBy: CardSortField.UPDATED_AT, + sortBy: CardSortField.LIBRARY_COUNT, sortOrder: SortOrder.DESC, }; @@ -626,25 +611,13 @@ describe('GetCollectionPageUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.urlCards[0]?.cardContent.title).toBe('Gamma Article'); // most recent - expect(response.urlCards[1]?.cardContent.title).toBe('Alpha Article'); - expect(response.urlCards[2]?.cardContent.title).toBe('Beta Article'); // oldest - }); - - it('should sort by created date ascending', async () => { - const query = { - collectionId: collectionId.getStringValue(), - sortBy: CardSortField.CREATED_AT, - sortOrder: SortOrder.ASC, - }; - - const result = await useCase.execute(query); - - expect(result.isOk()).toBe(true); - const response = result.unwrap(); - expect(response.urlCards[0]?.cardContent.title).toBe('Alpha Article'); // oldest - expect(response.urlCards[1]?.cardContent.title).toBe('Beta Article'); - expect(response.urlCards[2]?.cardContent.title).toBe('Gamma Article'); // newest + expect(response.urlCards).toHaveLength(3); + // All URL cards have library count of 1 (only creator's library) + expect(response.urlCards[0]?.libraryCount).toBe(1); + expect(response.urlCards[1]?.libraryCount).toBe(1); + expect(response.urlCards[2]?.libraryCount).toBe(1); + expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); + expect(response.sorting.sortOrder).toBe(SortOrder.DESC); }); it('should use default sorting when not specified', async () => { -- 2.51.2 From 4db3a7b2e913007c03044a1784e1dcdd8685358a Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:01:52 -0700 Subject: [PATCH 19/76] make sure to fail adding card to library if not allowed before publishing --- .../commands/UpdateNoteCardUseCase.ts | 3 +-- .../domain/services/CardLibraryService.ts | 21 +++++++++++++++++-- .../AddCardToLibraryUseCase.test.ts | 14 +++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts b/src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts index 3528d79f..f48fbfc6 100644 --- a/src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts +++ b/src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts @@ -95,7 +95,7 @@ export class UpdateNoteCardUseCase ); } - if (!noteContent.authorId.equals(curatorId)) { + if (!card.curatorId.equals(curatorId)) { return err( new ValidationError('Only the author can update this note card'), ); @@ -110,7 +110,6 @@ export class UpdateNoteCardUseCase // Create new card content with updated note const updatedCardContentResult = CardContent.createNoteContent( request.note, - curatorId, ); if (updatedCardContentResult.isErr()) { return err(new ValidationError(updatedCardContentResult.error.message)); diff --git a/src/modules/cards/domain/services/CardLibraryService.ts b/src/modules/cards/domain/services/CardLibraryService.ts index 58d8da29..048c7d25 100644 --- a/src/modules/cards/domain/services/CardLibraryService.ts +++ b/src/modules/cards/domain/services/CardLibraryService.ts @@ -37,6 +37,14 @@ export class CardLibraryService implements DomainService { // Card is already in library, nothing to do return ok(card); } + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + return err( + new CardLibraryValidationError( + `Failed to add card to library: ${addToLibResult.error.message}`, + ), + ); + } // Publish card to library const publishResult = await this.cardPublisher.publishCardToLibrary( @@ -52,8 +60,17 @@ export class CardLibraryService implements DomainService { } // Mark card as published in library - card.addToLibrary(curatorId); - card.markCardInLibraryAsPublished(curatorId, publishResult.value); + const markCardAsPublishedResult = card.markCardInLibraryAsPublished( + curatorId, + publishResult.value, + ); + if (markCardAsPublishedResult.isErr()) { + return err( + new CardLibraryValidationError( + `Failed to mark card as published in library: ${markCardAsPublishedResult.error.message}`, + ), + ); + } // Save updated card const saveResult = await this.cardRepository.save(card); diff --git a/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts b/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts index 43a17729..acf43311 100644 --- a/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/AddCardToLibraryUseCase.test.ts @@ -9,6 +9,7 @@ import { CuratorId } from '../../domain/value-objects/CuratorId'; import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; import { CardBuilder } from '../utils/builders/CardBuilder'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; +import { CARD_ERROR_MESSAGES } from '../../domain/Card'; describe('AddCardToLibraryUseCase', () => { let useCase: AddCardToLibraryUseCase; @@ -19,6 +20,7 @@ describe('AddCardToLibraryUseCase', () => { let cardLibraryService: CardLibraryService; let cardCollectionService: CardCollectionService; let curatorId: CuratorId; + let curatorId2: CuratorId; beforeEach(() => { cardRepository = new InMemoryCardRepository(); @@ -44,6 +46,7 @@ describe('AddCardToLibraryUseCase', () => { ); curatorId = CuratorId.create('did:plc:testcurator').unwrap(); + curatorId2 = CuratorId.create('did:plc:testcurator2').unwrap(); }); afterEach(() => { @@ -65,11 +68,18 @@ describe('AddCardToLibraryUseCase', () => { throw new Error(`Failed to create card: ${card.message}`); } + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error( + `Failed to add card to library: ${addToLibResult.error.message}`, + ); + } + await cardRepository.save(card); const request = { cardId: card.cardId.getStringValue(), - curatorId: curatorId.value, + curatorId: curatorId2.value, }; const result = await useCase.execute(request); @@ -79,7 +89,7 @@ describe('AddCardToLibraryUseCase', () => { } expect(result.isErr()).toBe(true); expect(result.error.message).toContain( - 'URL cards can only be in one library', + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, ); }); -- 2.51.2 From 485b24f4bf61cffa6e3e1a29358422b2783c003d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:03:35 -0700 Subject: [PATCH 20/76] feat: add curatorId to Card.create calls in test file Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../tests/application/GetMyUrlCardsUseCase.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index 99952c61..633aafa7 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -72,6 +72,7 @@ describe('GetUrlCardsUseCase', () => { const cardResult1 = Card.create( { + curatorId: curatorId, type: cardType1, content: cardContent1, url: url1, @@ -106,6 +107,7 @@ describe('GetUrlCardsUseCase', () => { const cardResult2 = Card.create( { + curatorId: curatorId, type: cardType2, content: cardContent2, url: url2, @@ -179,6 +181,7 @@ describe('GetUrlCardsUseCase', () => { // Create card const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -234,6 +237,7 @@ describe('GetUrlCardsUseCase', () => { const myCardResult = Card.create( { + curatorId: curatorId, type: myCardType, content: myCardContent, url: myUrl, @@ -264,6 +268,7 @@ describe('GetUrlCardsUseCase', () => { const otherCardResult = Card.create( { + curatorId: otherCuratorId, type: otherCardType, content: otherCardContent, url: otherUrl, @@ -316,6 +321,7 @@ describe('GetUrlCardsUseCase', () => { const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -425,6 +431,7 @@ describe('GetUrlCardsUseCase', () => { const alphaCardResult = Card.create( { + curatorId: curatorId, type: alphaCardType, content: alphaCardContent, url: alphaUrl, @@ -457,6 +464,7 @@ describe('GetUrlCardsUseCase', () => { const betaCardResult = Card.create( { + curatorId: curatorId, type: betaCardType, content: betaCardContent, url: betaUrl, @@ -497,6 +505,7 @@ describe('GetUrlCardsUseCase', () => { const gammaCardResult = Card.create( { + curatorId: curatorId, type: gammaCardType, content: gammaCardContent, url: gammaUrl, @@ -661,6 +670,7 @@ describe('GetUrlCardsUseCase', () => { const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -709,6 +719,7 @@ describe('GetUrlCardsUseCase', () => { const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, -- 2.51.2 From e28d2db75e69a418df3e357e9cf480adf300e5b9 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:07:06 -0700 Subject: [PATCH 21/76] minor updates to the test --- .../cards/tests/application/GetMyUrlCardsUseCase.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index 633aafa7..40c745ae 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -113,10 +113,6 @@ describe('GetUrlCardsUseCase', () => { url: url2, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-02') }, - { - curatorId: CuratorId.create('did:plc:othercurator').unwrap(), - addedAt: new Date('2023-01-03'), - }, ], createdAt: new Date('2023-01-02'), updatedAt: new Date('2023-01-02'), @@ -157,7 +153,7 @@ describe('GetUrlCardsUseCase', () => { expect(secondCard).toBeDefined(); expect(secondCard?.cardContent.title).toBe('Second Article'); - expect(secondCard?.libraryCount).toBe(2); + expect(secondCard?.libraryCount).toBe(1); }); it('should include collections and notes in URL cards', async () => { -- 2.51.2 From 66199c4cd7ac7afa89004a09a521c86193de9074 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:07:53 -0700 Subject: [PATCH 22/76] refactor: update test setups to enforce single library membership for URL cards Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../application/GetMyUrlCardsUseCase.test.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index 40c745ae..a8a1632f 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -466,16 +466,8 @@ describe('GetUrlCardsUseCase', () => { url: betaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime() - 1000) }, - { - curatorId: CuratorId.create('did:plc:othercurator').unwrap(), - addedAt: new Date(now.getTime() - 500), - }, - { - curatorId: CuratorId.create('did:plc:thirdcurator').unwrap(), - addedAt: new Date(now.getTime() - 300), - }, ], - libraryCount: 3, + libraryCount: 1, createdAt: new Date(now.getTime() - 1000), updatedAt: new Date(now.getTime() - 2000), }, @@ -507,12 +499,8 @@ describe('GetUrlCardsUseCase', () => { url: gammaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime()) }, - { - curatorId: CuratorId.create('did:plc:anothercurator').unwrap(), - addedAt: new Date(now.getTime() - 100), - }, ], - libraryCount: 2, + libraryCount: 1, createdAt: new Date(now.getTime()), updatedAt: new Date(now.getTime()), }, @@ -540,9 +528,10 @@ describe('GetUrlCardsUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(3); - expect(response.cards[0]?.libraryCount).toBe(3); // beta - expect(response.cards[1]?.libraryCount).toBe(2); // gamma - expect(response.cards[2]?.libraryCount).toBe(1); // alpha + // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) + expect(response.cards[0]?.libraryCount).toBe(1); // gamma (newest) + expect(response.cards[1]?.libraryCount).toBe(1); // beta + expect(response.cards[2]?.libraryCount).toBe(1); // alpha (oldest) expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); expect(response.sorting.sortOrder).toBe(SortOrder.DESC); }); @@ -558,9 +547,10 @@ describe('GetUrlCardsUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cards[0]?.libraryCount).toBe(1); // alpha - expect(response.cards[1]?.libraryCount).toBe(2); // gamma - expect(response.cards[2]?.libraryCount).toBe(3); // beta + // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) + expect(response.cards[0]?.libraryCount).toBe(1); // alpha (oldest) + expect(response.cards[1]?.libraryCount).toBe(1); // beta + expect(response.cards[2]?.libraryCount).toBe(1); // gamma (newest) }); it('should sort by updated date descending', async () => { -- 2.51.2 From 0dd1b89091057120916ab77f7ab79d79015901d7 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:08:03 -0700 Subject: [PATCH 23/76] feat: add libraryCount to cardResult2 in test case Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index a8a1632f..2ea338f4 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -114,6 +114,7 @@ describe('GetUrlCardsUseCase', () => { libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-02') }, ], + libraryCount: 1, createdAt: new Date('2023-01-02'), updatedAt: new Date('2023-01-02'), }, -- 2.51.2 From d62eb5f5fa1e1e65b14d3747beda2bdc8f75ba7d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:09:45 -0700 Subject: [PATCH 24/76] refactor: update GetUrlCardViewUseCase test to enforce single library for URL cards Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../application/GetUrlCardViewUseCase.test.ts | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts index 56df4616..f2e61cb5 100644 --- a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts @@ -4,7 +4,6 @@ import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; import { FakeProfileService } from '../utils/FakeProfileService'; import { CuratorId } from '../../domain/value-objects/CuratorId'; -import { CardId } from '../../domain/value-objects/CardId'; import { Card } from '../../domain/Card'; import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; import { CardContent } from '../../domain/value-objects/CardContent'; @@ -81,14 +80,14 @@ describe('GetUrlCardViewUseCase', () => { // Create card with library memberships const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-01') }, - { curatorId: otherCuratorId, addedAt: new Date('2023-01-01') }, ], - libraryCount: 2, + libraryCount: 1, createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), }, @@ -104,10 +103,10 @@ describe('GetUrlCardViewUseCase', () => { // now create a note card that references this URL card const noteCardResult = Card.create({ + curatorId: curatorId, type: CardType.create(CardTypeEnum.NOTE).unwrap(), content: CardContent.createNoteContent( 'This is my note about the article', - curatorId, ).unwrap(), parentCardId: card.cardId, url: url, @@ -152,7 +151,7 @@ describe('GetUrlCardViewUseCase', () => { expect(response.cardContent.thumbnailUrl).toBe( 'https://example.com/thumb1.jpg', ); - expect(response.libraryCount).toBe(2); + expect(response.libraryCount).toBe(1); // Verify collections expect(response.collections).toHaveLength(1); @@ -164,7 +163,7 @@ describe('GetUrlCardViewUseCase', () => { expect(response.note?.text).toBe('This is my note about the article'); // Verify enriched library data - expect(response.libraries).toHaveLength(2); + expect(response.libraries).toHaveLength(1); const testCuratorLib = response.libraries.find( (lib) => lib.userId === curatorId.value, @@ -173,16 +172,6 @@ describe('GetUrlCardViewUseCase', () => { expect(testCuratorLib?.name).toBe('Test Curator'); expect(testCuratorLib?.handle).toBe('testcurator'); expect(testCuratorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg'); - - const otherCuratorLib = response.libraries.find( - (lib) => lib.userId === otherCuratorId.value, - ); - expect(otherCuratorLib).toBeDefined(); - expect(otherCuratorLib?.name).toBe('Other Curator'); - expect(otherCuratorLib?.handle).toBe('othercurator'); - expect(otherCuratorLib?.avatarUrl).toBe( - 'https://example.com/avatar2.jpg', - ); }); it('should return URL card view with no libraries', async () => { @@ -204,6 +193,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card with no library memberships const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -244,6 +234,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card with minimal data const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -304,6 +295,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card const cardResult = Card.create( { + curatorId: minimalCuratorId, type: cardType, content: cardContent, url: url, @@ -386,6 +378,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, @@ -440,6 +433,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card with unknown user in library const cardResult = Card.create( { + curatorId: unknownCuratorId, type: cardType, content: cardContent, url: url, @@ -533,17 +527,20 @@ describe('GetUrlCardViewUseCase', () => { urlMetadata, ).unwrap(); - // Create card with multiple library memberships + // Create card with single library membership (URL cards can only be in creator's library) const cardResult = Card.create( { + curatorId: curatorIds[0]!, type: cardType, content: cardContent, url: url, - libraryMemberships: curatorIds.map((curatorId) => ({ - curatorId: curatorId, - addedAt: new Date(), - })), - libraryCount: 5, + libraryMemberships: [ + { + curatorId: curatorIds[0]!, + addedAt: new Date(), + }, + ], + libraryCount: 1, createdAt: new Date(), updatedAt: new Date(), }, @@ -565,18 +562,16 @@ describe('GetUrlCardViewUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.libraries).toHaveLength(5); - - // Verify all users are included with correct profile data - userIds.forEach((userId, index) => { - const userLib = response.libraries.find((lib) => lib.userId === userId); - expect(userLib).toBeDefined(); - expect(userLib?.name).toBe(`User ${index + 1}`); - expect(userLib?.handle).toBe(`user${index + 1}`); - expect(userLib?.avatarUrl).toBe( - `https://example.com/avatar${index + 1}.jpg`, - ); - }); + expect(response.libraries).toHaveLength(1); + + // Verify the creator is included with correct profile data + const creatorLib = response.libraries.find( + (lib) => lib.userId === userIds[0], + ); + expect(creatorLib).toBeDefined(); + expect(creatorLib?.name).toBe('User 1'); + expect(creatorLib?.handle).toBe('user1'); + expect(creatorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg'); }); it('should handle card with many collections', async () => { @@ -597,6 +592,7 @@ describe('GetUrlCardViewUseCase', () => { // Create card const cardResult = Card.create( { + curatorId: curatorId, type: cardType, content: cardContent, url: url, -- 2.51.2 From 3ffc389ebff93a30da41594d5dc6546af5a5bd84 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:12:41 -0700 Subject: [PATCH 25/76] refactor: update URL card removal tests to reflect single library constraint Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../RemoveCardFromLibraryUseCase.test.ts | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts index 15fef5c9..d92f65d1 100644 --- a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts @@ -136,12 +136,11 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(unpublishedCards).toHaveLength(1); }); - it("should remove card from one user's library without affecting others", async () => { + it("should remove URL card from creator's library", async () => { const card = await createCard(); - // Add card to both users' libraries + // Add card to creator's library only (URL cards can only be in creator's library) await addCardToLibrary(card, curatorId); - await addCardToLibrary(card, otherCuratorId); const request = { cardId: card.cardId.getStringValue(), @@ -152,13 +151,12 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card was removed from curatorId's library but not otherCuratorId's + // Verify card was removed from creator's library const updatedCardResult = await cardRepository.findById(card.cardId); const updatedCard = updatedCardResult.unwrap()!; expect(updatedCard.isInLibrary(curatorId)).toBe(false); - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); - // Verify only one unpublish operation occurred + // Verify unpublish operation occurred const unpublishedCards = cardPublisher.getUnpublishedCards(); expect(unpublishedCards).toHaveLength(1); }); @@ -419,26 +417,20 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(unpublishedCollectionLinks).toHaveLength(3); }); - it('should only remove card from curator collections, not other curator collections', async () => { + it('should remove URL card from creator collections only', async () => { const card = await createCard(); await addCardToLibrary(card, curatorId); - await addCardToLibrary(card, otherCuratorId); - // Create collections for both curators + // Create collections for the creator only (URL cards can only be in creator's library) const curatorCollection = await createCollection( curatorId, 'Curator Collection', ); - const otherCuratorCollection = await createCollection( - otherCuratorId, - 'Other Curator Collection', - ); - // Add card to both collections + // Add card to creator's collection await addCardToCollection(card, curatorCollection, curatorId); - await addCardToCollection(card, otherCuratorCollection, otherCuratorId); - // Remove card from first curator's library + // Remove card from creator's library const request = { cardId: card.cardId.getStringValue(), curatorId: curatorId.value, @@ -448,30 +440,21 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card was removed from curator's collection but not other curator's collection + // Verify card was removed from curator's collection const curatorCollectionResult = await collectionRepository.findById( curatorCollection.collectionId, ); - const otherCuratorCollectionResult = await collectionRepository.findById( - otherCuratorCollection.collectionId, - ); expect( curatorCollectionResult .unwrap()! .cardIds.some((id) => id.equals(card.cardId)), ).toBe(false); - expect( - otherCuratorCollectionResult - .unwrap()! - .cardIds.some((id) => id.equals(card.cardId)), - ).toBe(true); - // Verify card is still in other curator's library + // Verify card was removed from creator's library const updatedCardResult = await cardRepository.findById(card.cardId); const updatedCard = updatedCardResult.unwrap()!; expect(updatedCard.isInLibrary(curatorId)).toBe(false); - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); }); it('should handle card removal when no collections contain the card', async () => { @@ -504,14 +487,13 @@ describe('RemoveCardFromLibraryUseCase', () => { }); describe('Edge cases', () => { - it('should handle card with multiple library memberships', async () => { + it('should handle URL card with single library membership', async () => { const card = await createCard(); - // Add to multiple users' libraries + // Add to creator's library only (URL cards can only be in creator's library) await addCardToLibrary(card, curatorId); - await addCardToLibrary(card, otherCuratorId); - expect(card.libraryMembershipCount).toBe(2); + expect(card.libraryMembershipCount).toBe(1); const request = { cardId: card.cardId.getStringValue(), @@ -522,11 +504,11 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card still has one library membership + // Verify card has no library memberships after removal const updatedCardResult = await cardRepository.findById(card.cardId); const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.libraryMembershipCount).toBe(1); - expect(updatedCard.isInLibrary(otherCuratorId)).toBe(true); + expect(updatedCard.libraryMembershipCount).toBe(0); + expect(updatedCard.isInLibrary(curatorId)).toBe(false); }); it('should handle repository save failure', async () => { -- 2.51.2 From 4850f18b09951dc1038ff3b24cbfe12c1d38beaf Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:15:11 -0700 Subject: [PATCH 26/76] fix: enforce URL card library creation constraint in tests Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../RemoveCardFromLibraryUseCase.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts index d92f65d1..916eaf05 100644 --- a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts @@ -51,8 +51,8 @@ describe('RemoveCardFromLibraryUseCase', () => { cardPublisher.clear(); }); - const createCard = async (type: CardTypeEnum = CardTypeEnum.URL) => { - const card = new CardBuilder().withType(type).build(); + const createCard = async (type: CardTypeEnum = CardTypeEnum.URL, creatorId: CuratorId = curatorId) => { + const card = new CardBuilder().withType(type).withCuratorId(creatorId.value).build(); if (card instanceof Error) { throw new Error(`Failed to create card: ${card.message}`); @@ -63,6 +63,11 @@ describe('RemoveCardFromLibraryUseCase', () => { }; const addCardToLibrary = async (card: any, curatorId: CuratorId) => { + // For URL cards, can only add to creator's library + if (card.isUrlCard && !card.curatorId.equals(curatorId)) { + throw new Error('URL cards can only be added to creator\'s library'); + } + const addResult = await cardLibraryService.addCardToLibrary( card, curatorId, @@ -162,8 +167,10 @@ describe('RemoveCardFromLibraryUseCase', () => { }); it('should handle different card types', async () => { - const urlCard = await createCard(CardTypeEnum.URL); - const noteCard = await createCard(CardTypeEnum.NOTE); + // Create URL card with curatorId as creator + const urlCard = await createCard(CardTypeEnum.URL, curatorId); + // Create note card with curatorId as creator + const noteCard = await createCard(CardTypeEnum.NOTE, curatorId); await addCardToLibrary(urlCard, curatorId); await addCardToLibrary(noteCard, curatorId); @@ -488,7 +495,8 @@ describe('RemoveCardFromLibraryUseCase', () => { describe('Edge cases', () => { it('should handle URL card with single library membership', async () => { - const card = await createCard(); + // Create URL card with curatorId as creator + const card = await createCard(CardTypeEnum.URL, curatorId); // Add to creator's library only (URL cards can only be in creator's library) await addCardToLibrary(card, curatorId); @@ -529,7 +537,8 @@ describe('RemoveCardFromLibraryUseCase', () => { }); it('should preserve card properties when removing from library', async () => { - const card = await createCard(); + // Create URL card with curatorId as creator + const card = await createCard(CardTypeEnum.URL, curatorId); await addCardToLibrary(card, curatorId); const originalCreatedAt = card.createdAt; -- 2.51.2 From 5fad8f85049d55ab73b4a4b888e8cb73636f1d15 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:40:31 -0700 Subject: [PATCH 27/76] update update not card use case --- .../cards/tests/application/UpdateNoteCardUseCase.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modules/cards/tests/application/UpdateNoteCardUseCase.test.ts b/src/modules/cards/tests/application/UpdateNoteCardUseCase.test.ts index b4f10159..f3483a49 100644 --- a/src/modules/cards/tests/application/UpdateNoteCardUseCase.test.ts +++ b/src/modules/cards/tests/application/UpdateNoteCardUseCase.test.ts @@ -141,9 +141,7 @@ describe('UpdateNoteCardUseCase', () => { originalCreatedAt.getTime(), ); expect(updatedCard.type.value).toBe(CardTypeEnum.NOTE); - expect(updatedCard.content.noteContent!.authorId.equals(curatorId)).toBe( - true, - ); + expect(updatedCard.curatorId.equals(curatorId)).toBe(true); }); }); -- 2.51.2 From 8f94d0c995a0a8ba1d8a5fbb77ef3ab6e19904fd Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 10:59:04 -0700 Subject: [PATCH 28/76] delete card if its removed from all libraries and curator id matches --- .../commands/RemoveCardFromLibraryUseCase.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts index 0df3db29..ae7f3855 100644 --- a/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts @@ -87,6 +87,18 @@ export class RemoveCardFromLibraryUseCase return err(new ValidationError(removeFromLibraryResult.error.message)); } + const updatedCard = removeFromLibraryResult.value; + if ( + updatedCard.libraryCount == 0 && + updatedCard.curatorId.equals(curatorId) + ) { + // If no curators have this card in their library and the curator is the owner, delete the card + const deleteResult = await this.cardRepository.delete(card.cardId); + if (deleteResult.isErr()) { + return err(AppError.UnexpectedError.create(deleteResult.error)); + } + } + return ok({ cardId: card.cardId.getStringValue(), }); -- 2.51.2 From b3495924840edb5850b7d22f3705a56e7aea654a Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 11:03:11 -0700 Subject: [PATCH 29/76] test: update RemoveCardFromLibraryUseCase tests for card deletion behavior Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../RemoveCardFromLibraryUseCase.test.ts | 147 +++++++++++++----- 1 file changed, 111 insertions(+), 36 deletions(-) diff --git a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts index 916eaf05..90b2e2fa 100644 --- a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts @@ -131,10 +131,10 @@ describe('RemoveCardFromLibraryUseCase', () => { const response = result.unwrap(); expect(response.cardId).toBe(card.cardId.getStringValue()); - // Verify card was removed from library + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); // Verify unpublish operation occurred const unpublishedCards = cardPublisher.getUnpublishedCards(); @@ -156,10 +156,10 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card was removed from creator's library + // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); // Verify unpublish operation occurred const unpublishedCards = cardPublisher.getUnpublishedCards(); @@ -193,7 +193,7 @@ describe('RemoveCardFromLibraryUseCase', () => { const noteResult = await useCase.execute(noteRequest); expect(noteResult.isOk()).toBe(true); - // Verify both cards were removed + // Verify both cards were removed from library and deleted (since they're no longer in any libraries and curator is owner) const updatedUrlCardResult = await cardRepository.findById( urlCard.cardId, ); @@ -201,11 +201,11 @@ describe('RemoveCardFromLibraryUseCase', () => { noteCard.cardId, ); - const updatedUrlCard = updatedUrlCardResult.unwrap()!; - const updatedNoteCard = updatedNoteCardResult.unwrap()!; + const updatedUrlCard = updatedUrlCardResult.unwrap(); + const updatedNoteCard = updatedNoteCardResult.unwrap(); - expect(updatedUrlCard.isInLibrary(curatorId)).toBe(false); - expect(updatedNoteCard.isInLibrary(curatorId)).toBe(false); + expect(updatedUrlCard).toBeNull(); + expect(updatedNoteCard).toBeNull(); }); }); @@ -328,10 +328,10 @@ describe('RemoveCardFromLibraryUseCase', () => { const finalUnpublishCount = cardPublisher.getUnpublishedCards().length; expect(finalUnpublishCount).toBe(initialUnpublishCount); - // Verify card was still removed from library + // Verify card was still removed from library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); }); }); @@ -387,10 +387,10 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card was removed from library + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); // Verify card was removed from all collections const finalCollection1Result = await collectionRepository.findById( @@ -458,10 +458,10 @@ describe('RemoveCardFromLibraryUseCase', () => { .cardIds.some((id) => id.equals(card.cardId)), ).toBe(false); - // Verify card was removed from creator's library + // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); }); it('should handle card removal when no collections contain the card', async () => { @@ -481,10 +481,10 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card was removed from library + // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); // Verify no collection unpublish operations occurred const unpublishedCollections = @@ -493,6 +493,88 @@ describe('RemoveCardFromLibraryUseCase', () => { }); }); + describe('Card deletion behavior', () => { + it('should delete card when removed from last library and curator is owner', async () => { + const card = await createCard(); + await addCardToLibrary(card, curatorId); + + // Verify card exists and is in library + expect(card.isInLibrary(curatorId)).toBe(true); + expect(card.libraryMembershipCount).toBe(1); + + const request = { + cardId: card.cardId.getStringValue(), + curatorId: curatorId.value, + }; + + const result = await useCase.execute(request); + + expect(result.isOk()).toBe(true); + + // Verify card was deleted + const cardResult = await cardRepository.findById(card.cardId); + const cardFromRepo = cardResult.unwrap(); + expect(cardFromRepo).toBeNull(); + }); + + it('should not delete card when curator is not the owner', async () => { + // Create card with different owner + const card = await createCard(CardTypeEnum.NOTE, otherCuratorId); + + // Add to other curator's library first + await addCardToLibrary(card, otherCuratorId); + + // Add to current curator's library (note cards can be in multiple libraries) + await addCardToLibrary(card, curatorId); + + // Remove from current curator's library + const request = { + cardId: card.cardId.getStringValue(), + curatorId: curatorId.value, + }; + + const result = await useCase.execute(request); + + expect(result.isOk()).toBe(true); + + // Verify card still exists (not deleted because curator is not owner) + const cardResult = await cardRepository.findById(card.cardId); + const cardFromRepo = cardResult.unwrap()!; + expect(cardFromRepo).not.toBeNull(); + expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); + expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); + }); + + it('should not delete card when it still has other library memberships', async () => { + // Create note card (can be in multiple libraries) + const card = await createCard(CardTypeEnum.NOTE, curatorId); + + // Add to both curator's libraries + await addCardToLibrary(card, curatorId); + await addCardToLibrary(card, otherCuratorId); + + expect(card.libraryMembershipCount).toBe(2); + + // Remove from curator's library + const request = { + cardId: card.cardId.getStringValue(), + curatorId: curatorId.value, + }; + + const result = await useCase.execute(request); + + expect(result.isOk()).toBe(true); + + // Verify card still exists (not deleted because it's still in other curator's library) + const cardResult = await cardRepository.findById(card.cardId); + const cardFromRepo = cardResult.unwrap()!; + expect(cardFromRepo).not.toBeNull(); + expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); + expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); + expect(cardFromRepo.libraryMembershipCount).toBe(1); + }); + }); + describe('Edge cases', () => { it('should handle URL card with single library membership', async () => { // Create URL card with curatorId as creator @@ -512,11 +594,10 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card has no library memberships after removal + // Verify card was deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - expect(updatedCard.libraryMembershipCount).toBe(0); - expect(updatedCard.isInLibrary(curatorId)).toBe(false); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); }); it('should handle repository save failure', async () => { @@ -554,16 +635,10 @@ describe('RemoveCardFromLibraryUseCase', () => { expect(result.isOk()).toBe(true); - // Verify card properties are preserved + // Verify card was deleted (since it's no longer in any libraries and curator is owner) const updatedCardResult = await cardRepository.findById(card.cardId); - const updatedCard = updatedCardResult.unwrap()!; - - expect(updatedCard.createdAt).toEqual(originalCreatedAt); - expect(updatedCard.type.value).toBe(originalType); - expect(updatedCard.content).toEqual(originalContent); - expect(updatedCard.updatedAt.getTime()).toBeGreaterThanOrEqual( - originalCreatedAt.getTime(), - ); + const updatedCard = updatedCardResult.unwrap(); + expect(updatedCard).toBeNull(); }); }); }); -- 2.51.2 From ae7e6f7fd5cce380108f15781ce3afeb04adc305 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:04:07 -0700 Subject: [PATCH 30/76] refactor: update CardMapper to reflect Card aggregate changes Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../repositories/mappers/CardMapper.ts | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts index 958954a4..6e1e1f30 100644 --- a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts +++ b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts @@ -33,7 +33,6 @@ interface UrlContentData { interface NoteContentData { text: string; - authorId: string; } type CardContentData = UrlContentData | NoteContentData; @@ -60,11 +59,12 @@ export interface RawUrlCardData { // Database representation of a card export interface CardDTO { id: string; + curatorId: string; type: string; contentData: CardContentData; // Type-safe JSON data for the content url?: string; parentCardId?: string; - originalPublishedRecordId?: { + publishedRecordId?: { uri: string; cid: string; }; @@ -84,6 +84,9 @@ export interface CardDTO { export class CardMapper { public static toDomain(dto: CardDTO): Result { try { + const curatorIdOrError = CuratorId.create(dto.curatorId); + if (curatorIdOrError.isErr()) return err(curatorIdOrError.error); + const cardTypeOrError = CardType.create(dto.type as CardTypeEnum); if (cardTypeOrError.isErr()) return err(cardTypeOrError.error); @@ -110,12 +113,12 @@ export class CardMapper { parentCardId = parentCardIdOrError.value; } - // Create optional original published record ID - let originalPublishedRecordId: PublishedRecordId | undefined; - if (dto.originalPublishedRecordId) { - originalPublishedRecordId = PublishedRecordId.create({ - uri: dto.originalPublishedRecordId.uri, - cid: dto.originalPublishedRecordId.cid, + // Create optional published record ID + let publishedRecordId: PublishedRecordId | undefined; + if (dto.publishedRecordId) { + publishedRecordId = PublishedRecordId.create({ + uri: dto.publishedRecordId.uri, + cid: dto.publishedRecordId.cid, }); } const libraryMemberships = dto.libraryMemberships.map((membership) => { @@ -145,11 +148,12 @@ export class CardMapper { // Create the card const cardOrError = Card.create( { + curatorId: curatorIdOrError.value, type: cardTypeOrError.value, content: contentOrError.value, url, parentCardId, - originalPublishedRecordId, + publishedRecordId, libraryMemberships, libraryCount: dto.libraryCount, createdAt: dto.createdAt, @@ -206,12 +210,7 @@ export class CardMapper { case CardTypeEnum.NOTE: { const noteData = data as NoteContentData; - const authorIdResult = CuratorId.create(noteData.authorId); - if (authorIdResult.isErr()) return err(authorIdResult.error); - return CardContent.createNoteContent( - noteData.text, - authorIdResult.value, - ); + return CardContent.createNoteContent(noteData.text); } default: @@ -225,6 +224,7 @@ export class CardMapper { public static toPersistence(card: Card): { card: { id: string; + curatorId: string; type: string; contentData: CardContentData; url?: string; @@ -239,7 +239,7 @@ export class CardMapper { addedAt: Date; publishedRecordId?: string; }>; - originalPublishedRecord?: { + publishedRecord?: { id: string; uri: string; cid: string; @@ -276,22 +276,19 @@ export class CardMapper { } as UrlContentData; } else if (content.type === CardTypeEnum.NOTE) { const noteContent = content.noteContent!; - // For note content, we need to get the author ID from the content - // Since NoteCardContent now has authorId, we need to access it contentData = { text: noteContent.text, - authorId: (noteContent as any).props.authorId.value, // Access the authorId from props } as NoteContentData; } else { throw new Error(`Unknown card type: ${content.type}`); } // Collect all published records that need to be created - const originalPublishedRecord = card.originalPublishedRecordId + const publishedRecord = card.publishedRecordId ? { id: uuid(), - uri: card.originalPublishedRecordId.uri, - cid: card.originalPublishedRecordId.cid, + uri: card.publishedRecordId.uri, + cid: card.publishedRecordId.cid, } : undefined; @@ -325,6 +322,7 @@ export class CardMapper { return { card: { id: card.cardId.getStringValue(), + curatorId: card.curatorId.value, type: card.type.value, contentData, url: card.url?.value, @@ -334,7 +332,7 @@ export class CardMapper { updatedAt: card.updatedAt, }, libraryMemberships, - originalPublishedRecord, + publishedRecord, membershipPublishedRecords: membershipPublishedRecords.length > 0 ? membershipPublishedRecords -- 2.51.2 From 2ea741b62839fd941445c1080525b5106c6673f5 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:10:39 -0700 Subject: [PATCH 31/76] update card schema to include authorId --- .../cards/infrastructure/repositories/schema/card.sql.ts | 3 ++- src/modules/cards/tests/test-utils/createTestSchema.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts index d81a7d83..3503d2a4 100644 --- a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts @@ -11,11 +11,12 @@ import { publishedRecords } from './publishedRecord.sql'; export const cards: PgTableWithColumns = pgTable('cards', { id: uuid('id').primaryKey(), + authorId: text('author_id').notNull(), type: text('type').notNull(), // URL, NOTE, HIGHLIGHT contentData: jsonb('content_data').notNull(), url: text('url'), // Optional URL field for all card types parentCardId: uuid('parent_card_id').references(() => cards.id), - originalPublishedRecordId: uuid('original_published_record_id').references( + publishedRecordId: uuid('published_record_id').references( () => publishedRecords.id, ), libraryCount: integer('library_count').notNull().default(0), diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index 3c87000c..768c0bb4 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -19,6 +19,7 @@ export async function createTestSchema(db: PostgresJsDatabase) { // Cards table (references published_records and self-references) sql`CREATE TABLE IF NOT EXISTS cards ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + author_id TEXT NOT NULL, type TEXT NOT NULL, content_data JSONB NOT NULL, url TEXT, -- 2.51.2 From c1924f9a8242d03963c156e21d47290cf4617917 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:12:02 -0700 Subject: [PATCH 32/76] refactor: update DrizzleCardRepository to match ICardRepository interface and types Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../repositories/DrizzleCardRepository.ts | 89 +++++++++++-------- .../tests/test-utils/createTestSchema.ts | 2 +- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts index 95666a18..5459c84b 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts @@ -9,6 +9,7 @@ import { publishedRecords } from './schema/publishedRecord.sql'; import { CardDTO, CardMapper } from './mappers/CardMapper'; import { Result, ok, err } from '../../../../shared/core/Result'; import { URL } from '../../domain/value-objects/URL'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; export class DrizzleCardRepository implements ICardRepository { constructor(private db: PostgresJsDatabase) {} @@ -47,33 +48,34 @@ export class DrizzleCardRepository implements ICardRepository { ) .where(eq(libraryMemberships.cardId, cardId)); - // Get original published record if it exists - let originalPublishedRecord = null; - if (result.originalPublishedRecordId) { - const originalRecordResult = await this.db + // Get published record if it exists + let publishedRecord = null; + if (result.publishedRecordId) { + const publishedRecordResult = await this.db .select({ uri: publishedRecords.uri, cid: publishedRecords.cid, }) .from(publishedRecords) - .where(eq(publishedRecords.id, result.originalPublishedRecordId)) + .where(eq(publishedRecords.id, result.publishedRecordId)) .limit(1); - if (originalRecordResult.length > 0) { - originalPublishedRecord = originalRecordResult[0]; + if (publishedRecordResult.length > 0) { + publishedRecord = publishedRecordResult[0]; } } const cardDTO: CardDTO = { id: result.id, + curatorId: result.authorId, type: result.type, contentData: result.contentData, url: result.url || undefined, parentCardId: result.parentCardId || undefined, - originalPublishedRecordId: originalPublishedRecord + publishedRecordId: publishedRecord ? { - uri: originalPublishedRecord.uri, - cid: originalPublishedRecord.cid, + uri: publishedRecord.uri, + cid: publishedRecord.cid, } : undefined, libraryCount: result.libraryCount ?? 0, @@ -108,45 +110,45 @@ export class DrizzleCardRepository implements ICardRepository { const { card: cardData, libraryMemberships: membershipData, - originalPublishedRecord, + publishedRecord, membershipPublishedRecords, } = CardMapper.toPersistence(card); await this.db.transaction(async (tx) => { - // Handle original published record if it exists - let originalPublishedRecordId: string | undefined = undefined; + // Handle published record if it exists + let publishedRecordId: string | undefined = undefined; - if (originalPublishedRecord) { - const originalRecordResult = await tx + if (publishedRecord) { + const publishedRecordResult = await tx .insert(publishedRecords) .values({ - id: originalPublishedRecord.id, - uri: originalPublishedRecord.uri, - cid: originalPublishedRecord.cid, - recordedAt: originalPublishedRecord.recordedAt || new Date(), + id: publishedRecord.id, + uri: publishedRecord.uri, + cid: publishedRecord.cid, + recordedAt: publishedRecord.recordedAt || new Date(), }) .onConflictDoNothing({ target: [publishedRecords.uri, publishedRecords.cid], }) .returning({ id: publishedRecords.id }); - if (originalRecordResult.length === 0) { + if (publishedRecordResult.length === 0) { const existingRecord = await tx .select() .from(publishedRecords) .where( and( - eq(publishedRecords.uri, originalPublishedRecord.uri), - eq(publishedRecords.cid, originalPublishedRecord.cid), + eq(publishedRecords.uri, publishedRecord.uri), + eq(publishedRecords.cid, publishedRecord.cid), ), ) .limit(1); if (existingRecord.length > 0) { - originalPublishedRecordId = existingRecord[0]!.id; + publishedRecordId = existingRecord[0]!.id; } } else { - originalPublishedRecordId = originalRecordResult[0]!.id; + publishedRecordId = publishedRecordResult[0]!.id; } } @@ -201,16 +203,17 @@ export class DrizzleCardRepository implements ICardRepository { .insert(cards) .values({ ...cardData, - originalPublishedRecordId: originalPublishedRecordId, + publishedRecordId: publishedRecordId, }) .onConflictDoUpdate({ target: cards.id, set: { + authorId: cardData.curatorId, type: cardData.type, contentData: cardData.contentData, url: cardData.url, parentCardId: cardData.parentCardId, - originalPublishedRecordId: originalPublishedRecordId, + publishedRecordId: publishedRecordId, libraryCount: cardData.libraryCount, updatedAt: cardData.updatedAt, }, @@ -257,14 +260,23 @@ export class DrizzleCardRepository implements ICardRepository { } } - async findUrlCardByUrl(url: URL): Promise> { + async findUsersUrlCardByUrl( + url: URL, + curatorId: CuratorId, + ): Promise> { try { const urlValue = url.value; const cardResult = await this.db .select() .from(cards) - .where(and(eq(cards.url, urlValue), eq(cards.type, 'URL'))) + .where( + and( + eq(cards.url, urlValue), + eq(cards.type, 'URL'), + eq(cards.authorId, curatorId.value), + ), + ) .limit(1); if (cardResult.length === 0) { @@ -291,33 +303,34 @@ export class DrizzleCardRepository implements ICardRepository { ) .where(eq(libraryMemberships.cardId, result.id)); - // Get original published record if it exists - let originalPublishedRecord = null; - if (result.originalPublishedRecordId) { - const originalRecordResult = await this.db + // Get published record if it exists + let publishedRecord = null; + if (result.publishedRecordId) { + const publishedRecordResult = await this.db .select({ uri: publishedRecords.uri, cid: publishedRecords.cid, }) .from(publishedRecords) - .where(eq(publishedRecords.id, result.originalPublishedRecordId)) + .where(eq(publishedRecords.id, result.publishedRecordId)) .limit(1); - if (originalRecordResult.length > 0) { - originalPublishedRecord = originalRecordResult[0]; + if (publishedRecordResult.length > 0) { + publishedRecord = publishedRecordResult[0]; } } const cardDTO: CardDTO = { id: result.id, + curatorId: result.authorId, type: result.type, contentData: result.contentData, url: result.url || undefined, parentCardId: result.parentCardId || undefined, - originalPublishedRecordId: originalPublishedRecord + publishedRecordId: publishedRecord ? { - uri: originalPublishedRecord.uri, - cid: originalPublishedRecord.cid, + uri: publishedRecord.uri, + cid: publishedRecord.cid, } : undefined, libraryCount: result.libraryCount ?? 0, diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index 768c0bb4..ee33f160 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -24,7 +24,7 @@ export async function createTestSchema(db: PostgresJsDatabase) { content_data JSONB NOT NULL, url TEXT, parent_card_id UUID REFERENCES cards(id), - original_published_record_id UUID REFERENCES published_records(id), + published_record_id UUID REFERENCES published_records(id), library_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -- 2.51.2 From bfc0bf646fa1955f7a71f59a099cf5b7b22ae3d2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:17:37 -0700 Subject: [PATCH 33/76] refactor: update DrizzleCardRepository test to use new card creation pattern Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../DrizzleCardRepository.integration.test.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts index c7f3c5f0..4ea12edb 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts @@ -77,6 +77,7 @@ describe('DrizzleCardRepository', () => { const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: urlContent, url, @@ -109,11 +110,11 @@ describe('DrizzleCardRepository', () => { // Create a note card const noteContent = CardContent.createNoteContent( 'This is a test note', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -183,11 +184,11 @@ describe('DrizzleCardRepository', () => { // Create a note card const noteContent = CardContent.createNoteContent( 'Card for membership updates', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -229,11 +230,11 @@ describe('DrizzleCardRepository', () => { // Create a card const noteContent = CardContent.createNoteContent( 'Card to delete', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -263,11 +264,11 @@ describe('DrizzleCardRepository', () => { it('should return null when card is not found', async () => { const noteContent = CardContent.createNoteContent( 'Non-existent card', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const nonExistentCardId = Card.create({ + curatorId, type: cardType, content: noteContent, }).unwrap().cardId; @@ -277,15 +278,15 @@ describe('DrizzleCardRepository', () => { expect(result.unwrap()).toBeNull(); }); - it('should handle originalPublishedRecordId when marking card as published', async () => { + it('should handle publishedRecordId when marking card as published', async () => { // Create a note card const noteContent = CardContent.createNoteContent( 'Card for publishing test', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -295,7 +296,7 @@ describe('DrizzleCardRepository', () => { // Add to library card.addToLibrary(curatorId); - // Mark as published - this should set the originalPublishedRecordId + // Mark as published - this should set the publishedRecordId const publishedRecordId = { uri: 'at://did:plc:testcurator/network.cosmik.card/test123', cid: 'bafytest123', @@ -309,24 +310,24 @@ describe('DrizzleCardRepository', () => { ); expect(markResult.isOk()).toBe(true); - // Verify originalPublishedRecordId is set in memory - expect(card.originalPublishedRecordId).toBeDefined(); - expect(card.originalPublishedRecordId?.uri).toBe(publishedRecordId.uri); - expect(card.originalPublishedRecordId?.cid).toBe(publishedRecordId.cid); + // Verify publishedRecordId is set in memory + expect(card.publishedRecordId).toBeDefined(); + expect(card.publishedRecordId?.uri).toBe(publishedRecordId.uri); + expect(card.publishedRecordId?.cid).toBe(publishedRecordId.cid); - // Save the card - this should persist the originalPublishedRecordId + // Save the card - this should persist the publishedRecordId const saveResult = await cardRepository.save(card); expect(saveResult.isOk()).toBe(true); - // Retrieve and verify the originalPublishedRecordId persisted + // Retrieve and verify the publishedRecordId persisted const retrievedResult = await cardRepository.findById(card.cardId); const retrievedCard = retrievedResult.unwrap(); - expect(retrievedCard?.originalPublishedRecordId).toBeDefined(); - expect(retrievedCard?.originalPublishedRecordId?.uri).toBe( + expect(retrievedCard?.publishedRecordId).toBeDefined(); + expect(retrievedCard?.publishedRecordId?.uri).toBe( publishedRecordId.uri, ); - expect(retrievedCard?.originalPublishedRecordId?.cid).toBe( + expect(retrievedCard?.publishedRecordId?.cid).toBe( publishedRecordId.cid, ); }); @@ -345,6 +346,7 @@ describe('DrizzleCardRepository', () => { const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: urlContent, url, @@ -356,7 +358,7 @@ describe('DrizzleCardRepository', () => { await cardRepository.save(card); // Find the card by URL - const foundResult = await cardRepository.findUrlCardByUrl(url); + const foundResult = await cardRepository.findUsersUrlCardByUrl(url, curatorId); expect(foundResult.isOk()).toBe(true); const foundCard = foundResult.unwrap(); @@ -371,7 +373,7 @@ describe('DrizzleCardRepository', () => { it('should return null when URL card is not found', async () => { const nonExistentUrl = URL.create('https://example.com/notfound').unwrap(); - const result = await cardRepository.findUrlCardByUrl(nonExistentUrl); + const result = await cardRepository.findUsersUrlCardByUrl(nonExistentUrl, curatorId); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeNull(); }); @@ -381,11 +383,11 @@ describe('DrizzleCardRepository', () => { const url = URL.create('https://example.com/note-url').unwrap(); const noteContent = CardContent.createNoteContent( 'Note about a URL', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, url, // Note cards can have URLs too @@ -395,7 +397,7 @@ describe('DrizzleCardRepository', () => { await cardRepository.save(card); // Try to find it as a URL card - should return null because it's a NOTE type - const foundResult = await cardRepository.findUrlCardByUrl(url); + const foundResult = await cardRepository.findUsersUrlCardByUrl(url, curatorId); expect(foundResult.isOk()).toBe(true); expect(foundResult.unwrap()).toBeNull(); }); @@ -404,11 +406,11 @@ describe('DrizzleCardRepository', () => { // Create a note card const noteContent = CardContent.createNoteContent( 'Card for library count test', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -459,7 +461,6 @@ describe('DrizzleCardRepository', () => { // Create a note card with initial library memberships const noteContent = CardContent.createNoteContent( 'Card with initial memberships', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); @@ -475,6 +476,7 @@ describe('DrizzleCardRepository', () => { ]; const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, libraryMemberships: initialMemberships, -- 2.51.2 From c3fb342d76807038860d0f7298ca17331cc0233d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:31:58 -0700 Subject: [PATCH 34/76] feat: update CardMapper to use authorId instead of curatorId for persistence Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../infrastructure/repositories/DrizzleCardRepository.ts | 2 +- .../cards/infrastructure/repositories/mappers/CardMapper.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts index 5459c84b..aaf17b3a 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardRepository.ts @@ -208,7 +208,7 @@ export class DrizzleCardRepository implements ICardRepository { .onConflictDoUpdate({ target: cards.id, set: { - authorId: cardData.curatorId, + authorId: cardData.authorId, type: cardData.type, contentData: cardData.contentData, url: cardData.url, diff --git a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts index 6e1e1f30..914272ca 100644 --- a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts +++ b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts @@ -224,7 +224,7 @@ export class CardMapper { public static toPersistence(card: Card): { card: { id: string; - curatorId: string; + authorId: string; type: string; contentData: CardContentData; url?: string; @@ -322,7 +322,7 @@ export class CardMapper { return { card: { id: card.cardId.getStringValue(), - curatorId: card.curatorId.value, + authorId: card.curatorId.value, type: card.type.value, contentData, url: card.url?.value, -- 2.51.2 From 6c00fe82985cfab8283251215ce35853197372c1 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:33:16 -0700 Subject: [PATCH 35/76] refactor: Define CardPersistenceData type for CardMapper toPersistence method Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../repositories/mappers/CardMapper.ts | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts index 914272ca..1aaf286b 100644 --- a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts +++ b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts @@ -37,6 +37,39 @@ interface NoteContentData { type CardContentData = UrlContentData | NoteContentData; +// Persistence layer types +export interface CardPersistenceData { + card: { + id: string; + authorId: string; + type: string; + contentData: CardContentData; + url?: string; + parentCardId?: string; + libraryCount: number; + createdAt: Date; + updatedAt: Date; + }; + libraryMemberships: Array<{ + cardId: string; + userId: string; + addedAt: Date; + publishedRecordId?: string; + }>; + publishedRecord?: { + id: string; + uri: string; + cid: string; + recordedAt?: Date; + }; + membershipPublishedRecords?: Array<{ + id: string; + uri: string; + cid: string; + recordedAt?: Date; + }>; +} + // Raw data for URL card queries export interface RawUrlCardData { id: string; @@ -221,37 +254,7 @@ export class CardMapper { } } - public static toPersistence(card: Card): { - card: { - id: string; - authorId: string; - type: string; - contentData: CardContentData; - url?: string; - parentCardId?: string; - libraryCount: number; - createdAt: Date; - updatedAt: Date; - }; - libraryMemberships: Array<{ - cardId: string; - userId: string; - addedAt: Date; - publishedRecordId?: string; - }>; - publishedRecord?: { - id: string; - uri: string; - cid: string; - recordedAt?: Date; - }; - membershipPublishedRecords?: Array<{ - id: string; - uri: string; - cid: string; - recordedAt?: Date; - }>; - } { + public static toPersistence(card: Card): CardPersistenceData { const content = card.content; let contentData: CardContentData; -- 2.51.2 From 24ff29f5505f0d72c0c0eabc65db0ae2763b5e96 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:39:02 -0700 Subject: [PATCH 36/76] formatting of test file --- .../DrizzleCardRepository.integration.test.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts index 4ea12edb..aa2fda79 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCardRepository.integration.test.ts @@ -142,11 +142,11 @@ describe('DrizzleCardRepository', () => { // Create a note card const noteContent = CardContent.createNoteContent( 'Card with library memberships', - curatorId, ).unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ + curatorId, type: cardType, content: noteContent, }); @@ -228,9 +228,8 @@ describe('DrizzleCardRepository', () => { it('should delete a card and its library memberships', async () => { // Create a card - const noteContent = CardContent.createNoteContent( - 'Card to delete', - ).unwrap(); + const noteContent = + CardContent.createNoteContent('Card to delete').unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ @@ -262,9 +261,8 @@ describe('DrizzleCardRepository', () => { }); it('should return null when card is not found', async () => { - const noteContent = CardContent.createNoteContent( - 'Non-existent card', - ).unwrap(); + const noteContent = + CardContent.createNoteContent('Non-existent card').unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const nonExistentCardId = Card.create({ @@ -324,12 +322,8 @@ describe('DrizzleCardRepository', () => { const retrievedCard = retrievedResult.unwrap(); expect(retrievedCard?.publishedRecordId).toBeDefined(); - expect(retrievedCard?.publishedRecordId?.uri).toBe( - publishedRecordId.uri, - ); - expect(retrievedCard?.publishedRecordId?.cid).toBe( - publishedRecordId.cid, - ); + expect(retrievedCard?.publishedRecordId?.uri).toBe(publishedRecordId.uri); + expect(retrievedCard?.publishedRecordId?.cid).toBe(publishedRecordId.cid); }); it('should find URL card by URL', async () => { @@ -358,7 +352,10 @@ describe('DrizzleCardRepository', () => { await cardRepository.save(card); // Find the card by URL - const foundResult = await cardRepository.findUsersUrlCardByUrl(url, curatorId); + const foundResult = await cardRepository.findUsersUrlCardByUrl( + url, + curatorId, + ); expect(foundResult.isOk()).toBe(true); const foundCard = foundResult.unwrap(); @@ -373,7 +370,10 @@ describe('DrizzleCardRepository', () => { it('should return null when URL card is not found', async () => { const nonExistentUrl = URL.create('https://example.com/notfound').unwrap(); - const result = await cardRepository.findUsersUrlCardByUrl(nonExistentUrl, curatorId); + const result = await cardRepository.findUsersUrlCardByUrl( + nonExistentUrl, + curatorId, + ); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeNull(); }); @@ -381,9 +381,8 @@ describe('DrizzleCardRepository', () => { it('should not find note cards when searching by URL', async () => { // Create a note card with a URL (but it's not a URL card type) const url = URL.create('https://example.com/note-url').unwrap(); - const noteContent = CardContent.createNoteContent( - 'Note about a URL', - ).unwrap(); + const noteContent = + CardContent.createNoteContent('Note about a URL').unwrap(); const cardType = CardType.create(CardTypeEnum.NOTE).unwrap(); const cardResult = Card.create({ @@ -397,7 +396,10 @@ describe('DrizzleCardRepository', () => { await cardRepository.save(card); // Try to find it as a URL card - should return null because it's a NOTE type - const foundResult = await cardRepository.findUsersUrlCardByUrl(url, curatorId); + const foundResult = await cardRepository.findUsersUrlCardByUrl( + url, + curatorId, + ); expect(foundResult.isOk()).toBe(true); expect(foundResult.unwrap()).toBeNull(); }); -- 2.51.2 From 54e233582b9b7b8ebc00c3756b6cd7ec0fd5b680 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 12:39:31 -0700 Subject: [PATCH 37/76] update GetUrlMetadataUseCase to no longer fetch an existing card for a url --- .../useCases/queries/GetUrlMetadataUseCase.ts | 16 ---------------- src/webapp/api-client/types/responses.ts | 1 - 2 files changed, 17 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts index a0a9187f..da750c15 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlMetadataUseCase.ts @@ -18,7 +18,6 @@ export interface GetUrlMetadataResult { imageUrl?: string; type?: string; }; - existingCardId?: string; } export class ValidationError extends Error { @@ -49,20 +48,6 @@ export class GetUrlMetadataUseCase const url = urlResult.value; try { - // Check if a card already exists for this URL - const existingCardResult = - await this.cardRepository.findUrlCardByUrl(url); - if (existingCardResult.isErr()) { - return err( - new Error( - `Failed to check for existing card: ${existingCardResult.error instanceof Error ? existingCardResult.error.message : 'Unknown error'}`, - ), - ); - } - - const existingCard = existingCardResult.value; - const existingCardId = existingCard?.id?.toString(); - // Fetch metadata from external service const metadataResult = await this.metadataService.fetchMetadata(url); if (metadataResult.isErr()) { @@ -85,7 +70,6 @@ export class GetUrlMetadataUseCase imageUrl: metadata.imageUrl, type: metadata.type, }, - existingCardId, }); } catch (error) { return err( diff --git a/src/webapp/api-client/types/responses.ts b/src/webapp/api-client/types/responses.ts index 7bfde81b..1e76e84e 100644 --- a/src/webapp/api-client/types/responses.ts +++ b/src/webapp/api-client/types/responses.ts @@ -49,7 +49,6 @@ export interface UrlMetadata { export interface GetUrlMetadataResponse { metadata: UrlMetadata; - existingCardId?: string; } export interface UrlCardView { -- 2.51.2 From 5c17f18f5addc0f28ccdc9156e39aed20d935f9b Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:00:23 -0700 Subject: [PATCH 38/76] delete annotation lexicons --- lexicons.json | 3 - .../infrastructure/lexicons/annotation.json | 140 ------------------ .../lexicons/annotationField.json | 112 -------------- .../lexicons/annotationTemplate.json | 53 ------- 4 files changed, 308 deletions(-) delete mode 100644 lexicons.json delete mode 100644 src/modules/atproto/infrastructure/lexicons/annotation.json delete mode 100644 src/modules/atproto/infrastructure/lexicons/annotationField.json delete mode 100644 src/modules/atproto/infrastructure/lexicons/annotationTemplate.json diff --git a/lexicons.json b/lexicons.json deleted file mode 100644 index dd636f2f..00000000 --- a/lexicons.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lexicons": ["com.atproto.repo.strongRef"] -} diff --git a/src/modules/atproto/infrastructure/lexicons/annotation.json b/src/modules/atproto/infrastructure/lexicons/annotation.json deleted file mode 100644 index 1f8d3b4c..00000000 --- a/src/modules/atproto/infrastructure/lexicons/annotation.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "lexicon": 1, - "id": "network.cosmik.annotation", - "description": "A single record type for all annotations.", - "defs": { - "main": { - "type": "record", - "description": "A record representing an annotation on a resource.", - "key": "tid", - "record": { - "type": "object", - "required": ["url", "field", "value"], - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "The primary URL identifying the annotated resource." - }, - "additionalIdentifiers": { - "type": "array", - "items": { - "type": "ref", - "ref": "network.cosmik.defs#identifier" - }, - "description": "Optional additional identifiers for the resource." - }, - "field": { - "type": "ref", - "description": "A strong reference to the specific annotation field record being used.", - "ref": "com.atproto.repo.strongRef" - }, - "fromTemplates": { - "type": "array", - "items": { - "type": "ref", - "description": "Optional strong reference to the template record used.", - "ref": "com.atproto.repo.strongRef" - } - }, - "note": { - "type": "string", - "description": "An optional user-provided note about the annotation." - }, - "value": { - "type": "union", - "description": "The specific value of the annotation, determined by the field type.", - "refs": [ - "#dyadValue", - "#triadValue", - "#ratingValue", - "#singleSelectValue", - "#multiSelectValue" - ] - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp when this annotation was created (usually set by PDS)." - } - } - } - }, - "dyadValue": { - "type": "object", - "description": "Value structure for a dyad annotation.", - "required": ["value"], - "properties": { - "value": { - "type": "integer", - "description": "Value representing the relationship between sides", - "minimum": 0, - "maximum": 100 - } - } - }, - "triadValue": { - "type": "object", - "description": "Value structure for a triad annotation.", - "required": ["vertexA", "vertexB", "vertexC"], - "properties": { - "vertexA": { - "type": "integer", - "description": "Value for vertex A", - "minimum": 0, - "maximum": 1000 - }, - "vertexB": { - "type": "integer", - "description": "Value for vertex B", - "minimum": 0, - "maximum": 1000 - }, - "vertexC": { - "type": "integer", - "description": "Value for vertex C", - "minimum": 0, - "maximum": 1000 - }, - "sum": { - "type": "integer", - "description": "Sum of the values for the vertices", - "const": 1000 - } - } - }, - "ratingValue": { - "type": "object", - "description": "Value structure for a rating annotation.", - "required": ["rating"], - "properties": { - "rating": { - "type": "integer", - "description": "The star rating value", - "minimum": 1, - "maximum": 10 - } - } - }, - "singleSelectValue": { - "type": "object", - "description": "Value structure for a single-select annotation.", - "required": ["option"], - "properties": { - "option": { "type": "string", "description": "The selected option" } - } - }, - "multiSelectValue": { - "type": "object", - "description": "Value structure for a multi-select annotation.", - "required": ["options"], - "properties": { - "options": { - "type": "array", - "description": "The selected options", - "items": { "type": "string" } - } - } - } - } -} diff --git a/src/modules/atproto/infrastructure/lexicons/annotationField.json b/src/modules/atproto/infrastructure/lexicons/annotationField.json deleted file mode 100644 index 993079e6..00000000 --- a/src/modules/atproto/infrastructure/lexicons/annotationField.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "lexicon": 1, - "id": "network.cosmik.annotationField", - "description": "A single record type for all annotation fields.", - "defs": { - "main": { - "type": "record", - "description": "A record defining an annotation field.", - "key": "tid", - "record": { - "type": "object", - "required": ["name", "description", "definition"], - "properties": { - "name": { - "type": "string", - "description": "Name of the annotation field" - }, - "description": { - "type": "string", - "description": "Description of the annotation field" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp when this field was created" - }, - "definition": { - "type": "union", - "description": "The specific definition of the field, determining its type and constraints.", - "refs": [ - "#dyadFieldDef", - "#triadFieldDef", - "#ratingFieldDef", - "#singleSelectFieldDef", - "#multiSelectFieldDef" - ] - } - } - } - }, - "dyadFieldDef": { - "type": "object", - "description": "Definition structure for a dyad field.", - "required": ["sideA", "sideB"], - "properties": { - "sideA": { - "type": "string", - "description": "Label for side A of the dyad" - }, - "sideB": { - "type": "string", - "description": "Label for side B of the dyad" - } - } - }, - "triadFieldDef": { - "type": "object", - "description": "Definition structure for a triad field.", - "required": ["vertexA", "vertexB", "vertexC"], - "properties": { - "vertexA": { - "type": "string", - "description": "Label for vertex A of the triad" - }, - "vertexB": { - "type": "string", - "description": "Label for vertex B of the triad" - }, - "vertexC": { - "type": "string", - "description": "Label for vertex C of the triad" - } - } - }, - "ratingFieldDef": { - "type": "object", - "description": "Definition structure for a rating field.", - "required": ["numberOfStars"], - "properties": { - "numberOfStars": { - "type": "integer", - "description": "Maximum number of stars for the rating", - "const": 5 - } - } - }, - "singleSelectFieldDef": { - "type": "object", - "description": "Definition structure for a single-select field.", - "required": ["options"], - "properties": { - "options": { - "type": "array", - "description": "Available options for selection", - "items": { "type": "string" } - } - } - }, - "multiSelectFieldDef": { - "type": "object", - "description": "Definition structure for a multi-select field.", - "required": ["options"], - "properties": { - "options": { - "type": "array", - "description": "Available options for selection", - "items": { "type": "string" } - } - } - } - } -} diff --git a/src/modules/atproto/infrastructure/lexicons/annotationTemplate.json b/src/modules/atproto/infrastructure/lexicons/annotationTemplate.json deleted file mode 100644 index 96bfe527..00000000 --- a/src/modules/atproto/infrastructure/lexicons/annotationTemplate.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "lexicon": 1, - "id": "network.cosmik.annotationTemplate", - "description": "Annotation templates for grouping annotation fields", - "defs": { - "main": { - "type": "record", - "description": "A record of an annotation template", - "key": "tid", - "record": { - "type": "object", - "required": ["name", "description", "annotationFields"], - "properties": { - "name": { - "type": "string", - "description": "Name of the template" - }, - "description": { - "type": "string", - "description": "Description of the template" - }, - "annotationFields": { - "type": "array", - "description": "List of strong references to network.cosmik.annotationField records included in this template.", - "items": { - "type": "ref", - "ref": "#annotationFieldRef" - } - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp when this template was created" - } - } - } - }, - "annotationFieldRef": { - "type": "object", - "description": "A reference to an annotation field. Defines if the field is required in the template.", - "required": ["subject"], - "properties": { - "subject": { - "type": "ref", - "ref": "com.atproto.repo.strongRef" - }, - "required": { - "type": "boolean" - } - } - } - } -} -- 2.51.2 From d28c8b9c9ecf8b10702c9596fd8deed3e6ea6bec Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:09:47 -0700 Subject: [PATCH 39/76] feat: add environment-based collection names for ATProto publishers Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../publishers/ATProtoCardPublisher.ts | 13 +++++++------ .../publishers/ATProtoCollectionPublisher.ts | 19 ++++++++++--------- .../config/EnvironmentConfigService.ts | 14 ++++++++++++++ .../http/factories/ServiceFactory.ts | 12 ++++++++++-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts index 60e2ead2..0b7ac753 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts @@ -9,9 +9,10 @@ import { IAgentService } from '../../application/IAgentService'; import { DID } from '../../domain/DID'; import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; export class ATProtoCardPublisher implements ICardPublisher { - private readonly COLLECTION = 'network.cosmik.card'; - - constructor(private readonly agentService: IAgentService) {} + constructor( + private readonly agentService: IAgentService, + private readonly cardCollection: string, + ) {} /** * Publishes a Card to the curator's library in the AT Protocol @@ -65,7 +66,7 @@ export class ATProtoCardPublisher implements ICardPublisher { await agent.com.atproto.repo.putRecord({ repo: curatorDid.value, - collection: this.COLLECTION, + collection: this.cardCollection, rkey: rkey, record, }); @@ -75,7 +76,7 @@ export class ATProtoCardPublisher implements ICardPublisher { // Create new record const createResult = await agent.com.atproto.repo.createRecord({ repo: curatorDid.value, - collection: this.COLLECTION, + collection: this.cardCollection, record, }); @@ -134,7 +135,7 @@ export class ATProtoCardPublisher implements ICardPublisher { await agent.com.atproto.repo.deleteRecord({ repo, - collection: this.COLLECTION, + collection: this.cardCollection, rkey, }); diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts index cb0e371a..e1a73624 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts @@ -12,10 +12,11 @@ import { IAgentService } from '../../application/IAgentService'; import { DID } from '../../domain/DID'; export class ATProtoCollectionPublisher implements ICollectionPublisher { - private readonly COLLECTION_COLLECTION = 'network.cosmik.collection'; - private readonly COLLECTION_LINK_COLLECTION = 'network.cosmik.collectionLink'; - - constructor(private readonly agentService: IAgentService) {} + constructor( + private readonly agentService: IAgentService, + private readonly collectionCollection: string, + private readonly collectionLinkCollection: string, + ) {} /** * Publishes a Collection record only (not the card links) @@ -62,7 +63,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { await agent.com.atproto.repo.putRecord({ repo: curatorDid.value, - collection: this.COLLECTION_COLLECTION, + collection: this.collectionCollection, rkey: rkey, record: collectionRecordDTO, }); @@ -75,7 +76,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { const createResult = await agent.com.atproto.repo.createRecord({ repo: curatorDid.value, - collection: this.COLLECTION_COLLECTION, + collection: this.collectionCollection, record: collectionRecordDTO, }); @@ -171,7 +172,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { const createResult = await agent.com.atproto.repo.createRecord({ repo: curatorDid.value, - collection: this.COLLECTION_LINK_COLLECTION, + collection: this.collectionLinkCollection, record: linkRecordDTO, }); @@ -220,7 +221,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { await agent.com.atproto.repo.deleteRecord({ repo, - collection: this.COLLECTION_LINK_COLLECTION, + collection: this.collectionLinkCollection, rkey, }); @@ -265,7 +266,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { // Delete the collection record await agent.com.atproto.repo.deleteRecord({ repo, - collection: this.COLLECTION_COLLECTION, + collection: this.collectionCollection, rkey, }); diff --git a/src/shared/infrastructure/config/EnvironmentConfigService.ts b/src/shared/infrastructure/config/EnvironmentConfigService.ts index 56a4b863..aa097dac 100644 --- a/src/shared/infrastructure/config/EnvironmentConfigService.ts +++ b/src/shared/infrastructure/config/EnvironmentConfigService.ts @@ -11,6 +11,11 @@ export interface EnvironmentConfig { atproto: { serviceEndpoint: string; baseUrl: string; + collections: { + card: string; + collection: string; + collectionLink: string; + }; }; server: { port: number; @@ -63,6 +68,11 @@ export class EnvironmentConfigService { serviceEndpoint: process.env.ATPROTO_SERVICE_ENDPOINT || 'https://bsky.social', baseUrl: process.env.BASE_URL || 'http://127.0.0.1:3000', + collections: { + card: environment === 'prod' ? 'network.cosmik.card' : 'network.cosmik.dev.card', + collection: environment === 'prod' ? 'network.cosmik.collection' : 'network.cosmik.dev.collection', + collectionLink: environment === 'prod' ? 'network.cosmik.collectionLink' : 'network.cosmik.dev.collectionLink', + }, }, server: { port: parseInt(process.env.PORT || '3000'), @@ -124,6 +134,10 @@ export class EnvironmentConfigService { return this.config.atproto; } + public getAtProtoCollections() { + return this.config.atproto.collections; + } + public getServerConfig() { return this.config.server; } diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index e365cd64..3abd4d14 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -123,14 +123,22 @@ export class ServiceFactory { : new AtProtoOAuthProcessor(sharedServices.nodeOauthClient); const useFakePublishers = process.env.USE_FAKE_PUBLISHERS === 'true'; + const collections = configService.getAtProtoCollections(); const collectionPublisher = useFakePublishers ? new FakeCollectionPublisher() - : new ATProtoCollectionPublisher(sharedServices.atProtoAgentService); + : new ATProtoCollectionPublisher( + sharedServices.atProtoAgentService, + collections.collection, + collections.collectionLink, + ); const cardPublisher = useFakePublishers ? new FakeCardPublisher() - : new ATProtoCardPublisher(sharedServices.atProtoAgentService); + : new ATProtoCardPublisher( + sharedServices.atProtoAgentService, + collections.card, + ); const cardCollectionService = new CardCollectionService( repositories.collectionRepository, -- 2.51.2 From 75dec2abf57ac80cd8c9a3a43654a4471a3e62b3 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:14:05 -0700 Subject: [PATCH 40/76] updates to the card lexicon --- src/modules/atproto/infrastructure/lexicons/card.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/modules/atproto/infrastructure/lexicons/card.json b/src/modules/atproto/infrastructure/lexicons/card.json index 5d2b165c..79d92d58 100644 --- a/src/modules/atproto/infrastructure/lexicons/card.json +++ b/src/modules/atproto/infrastructure/lexicons/card.json @@ -76,13 +76,7 @@ "urlMetadata": { "type": "object", "description": "Metadata about a URL.", - "required": ["url"], "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "The URL" - }, "title": { "type": "string", "description": "Title of the page" -- 2.51.2 From 5d4fa69e294e95cda2be1811ae6583b0a9e47a0b Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:16:50 -0700 Subject: [PATCH 41/76] update migration files after schema changes --- .../migrations/0004_brainy_rocket_racer.sql | 5 + .../migrations/meta/0004_snapshot.json | 767 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 9 +- 3 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql create mode 100644 src/shared/infrastructure/database/migrations/meta/0004_snapshot.json diff --git a/src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql b/src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql new file mode 100644 index 00000000..b80d33bf --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql @@ -0,0 +1,5 @@ +ALTER TABLE "cards" RENAME COLUMN "original_published_record_id" TO "published_record_id";--> statement-breakpoint +ALTER TABLE "cards" DROP CONSTRAINT "cards_original_published_record_id_published_records_id_fk"; +--> statement-breakpoint +ALTER TABLE "cards" ADD COLUMN "author_id" text NOT NULL;--> statement-breakpoint +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; \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..bfb9e0ce --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json @@ -0,0 +1,767 @@ +{ + "id": "1e15b3e8-04ba-4e0a-965e-07142597ddc4", + "prevId": "69abc7ad-c872-43e9-8e83-4d9362cf62c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": [ + "parent_card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": [ + "card_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_did" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index c3c0457e..eaee835e 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1758934838107, "tag": "0003_harsh_lady_mastermind", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1759875361391, + "tag": "0004_brainy_rocket_racer", + "breakpoints": true } ] -} +} \ No newline at end of file -- 2.51.2 From 0212f884be8e78d053b019984eecc37c763153d3 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:20:16 -0700 Subject: [PATCH 42/76] rerun lexgen after lexicon updates --- .../atproto/infrastructure/lexicon/index.ts | 84 ++-- .../infrastructure/lexicon/lexicons.ts | 367 ++---------------- .../types/com/atproto/repo/strongRef.ts | 24 +- .../types/network/cosmik/annotation.ts | 140 ------- .../types/network/cosmik/annotationField.ts | 134 ------- .../network/cosmik/annotationTemplate.ts | 56 --- .../lexicon/types/network/cosmik/card.ts | 88 ++--- .../types/network/cosmik/collection.ts | 38 +- .../types/network/cosmik/collectionLink.ts | 39 +- .../lexicon/types/network/cosmik/defs.ts | 28 +- .../atproto/infrastructure/lexicon/util.ts | 32 +- 11 files changed, 182 insertions(+), 848 deletions(-) delete mode 100644 src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotation.ts delete mode 100644 src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationField.ts delete mode 100644 src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationTemplate.ts diff --git a/src/modules/atproto/infrastructure/lexicon/index.ts b/src/modules/atproto/infrastructure/lexicon/index.ts index 21d14790..9d24d6c3 100644 --- a/src/modules/atproto/infrastructure/lexicon/index.ts +++ b/src/modules/atproto/infrastructure/lexicon/index.ts @@ -7,93 +7,93 @@ import { type Options as XrpcOptions, type AuthVerifier, type StreamAuthVerifier, -} from '@atproto/xrpc-server'; -import { schemas } from './lexicons.js'; +} from '@atproto/xrpc-server' +import { schemas } from './lexicons.js' export function createServer(options?: XrpcOptions): Server { - return new Server(options); + return new Server(options) } export class Server { - xrpc: XrpcServer; - network: NetworkNS; - com: ComNS; + xrpc: XrpcServer + network: NetworkNS + com: ComNS constructor(options?: XrpcOptions) { - this.xrpc = createXrpcServer(schemas, options); - this.network = new NetworkNS(this); - this.com = new ComNS(this); + this.xrpc = createXrpcServer(schemas, options) + this.network = new NetworkNS(this) + this.com = new ComNS(this) } } export class NetworkNS { - _server: Server; - cosmik: NetworkCosmikNS; + _server: Server + cosmik: NetworkCosmikNS constructor(server: Server) { - this._server = server; - this.cosmik = new NetworkCosmikNS(server); + this._server = server + this.cosmik = new NetworkCosmikNS(server) } } export class NetworkCosmikNS { - _server: Server; + _server: Server constructor(server: Server) { - this._server = server; + this._server = server } } export class ComNS { - _server: Server; - atproto: ComAtprotoNS; + _server: Server + atproto: ComAtprotoNS constructor(server: Server) { - this._server = server; - this.atproto = new ComAtprotoNS(server); + this._server = server + this.atproto = new ComAtprotoNS(server) } } export class ComAtprotoNS { - _server: Server; - repo: ComAtprotoRepoNS; + _server: Server + repo: ComAtprotoRepoNS constructor(server: Server) { - this._server = server; - this.repo = new ComAtprotoRepoNS(server); + this._server = server + this.repo = new ComAtprotoRepoNS(server) } } export class ComAtprotoRepoNS { - _server: Server; + _server: Server constructor(server: Server) { - this._server = server; + this._server = server } } type SharedRateLimitOpts = { - name: string; - calcKey?: (ctx: T) => string | null; - calcPoints?: (ctx: T) => number; -}; + name: string + calcKey?: (ctx: T) => string | null + calcPoints?: (ctx: T) => number +} type RouteRateLimitOpts = { - durationMs: number; - points: number; - calcKey?: (ctx: T) => string | null; - calcPoints?: (ctx: T) => number; -}; -type HandlerOpts = { blobLimit?: number }; -type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts; + durationMs: number + points: number + calcKey?: (ctx: T) => string | null + calcPoints?: (ctx: T) => number +} +type HandlerOpts = { blobLimit?: number } +type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { - auth?: Auth; - opts?: HandlerOpts; - rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[]; - handler: Handler; - }; + auth?: Auth + opts?: HandlerOpts + rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] + handler: Handler + } type ExtractAuth = Extract< Awaited>, { credentials: unknown } ->; +> diff --git a/src/modules/atproto/infrastructure/lexicon/lexicons.ts b/src/modules/atproto/infrastructure/lexicon/lexicons.ts index f3106a3f..1972eec8 100644 --- a/src/modules/atproto/infrastructure/lexicon/lexicons.ts +++ b/src/modules/atproto/infrastructure/lexicon/lexicons.ts @@ -6,333 +6,10 @@ import { Lexicons, ValidationError, type ValidationResult, -} from '@atproto/lexicon'; -import { type $Typed, is$typed, maybe$typed } from './util.js'; +} from '@atproto/lexicon' +import { type $Typed, is$typed, maybe$typed } from './util.js' export const schemaDict = { - NetworkCosmikAnnotation: { - lexicon: 1, - id: 'network.cosmik.annotation', - description: 'A single record type for all annotations.', - defs: { - main: { - type: 'record', - description: 'A record representing an annotation on a resource.', - key: 'tid', - record: { - type: 'object', - required: ['url', 'field', 'value'], - properties: { - url: { - type: 'string', - format: 'uri', - description: - 'The primary URL identifying the annotated resource.', - }, - additionalIdentifiers: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:network.cosmik.defs#identifier', - }, - description: 'Optional additional identifiers for the resource.', - }, - field: { - type: 'ref', - description: - 'A strong reference to the specific annotation field record being used.', - ref: 'lex:com.atproto.repo.strongRef', - }, - fromTemplates: { - type: 'array', - items: { - type: 'ref', - description: - 'Optional strong reference to the template record used.', - ref: 'lex:com.atproto.repo.strongRef', - }, - }, - note: { - type: 'string', - description: - 'An optional user-provided note about the annotation.', - }, - value: { - type: 'union', - description: - 'The specific value of the annotation, determined by the field type.', - refs: [ - 'lex:network.cosmik.annotation#dyadValue', - 'lex:network.cosmik.annotation#triadValue', - 'lex:network.cosmik.annotation#ratingValue', - 'lex:network.cosmik.annotation#singleSelectValue', - 'lex:network.cosmik.annotation#multiSelectValue', - ], - }, - createdAt: { - type: 'string', - format: 'datetime', - description: - 'Timestamp when this annotation was created (usually set by PDS).', - }, - }, - }, - }, - dyadValue: { - type: 'object', - description: 'Value structure for a dyad annotation.', - required: ['value'], - properties: { - value: { - type: 'integer', - description: 'Value representing the relationship between sides', - minimum: 0, - maximum: 100, - }, - }, - }, - triadValue: { - type: 'object', - description: 'Value structure for a triad annotation.', - required: ['vertexA', 'vertexB', 'vertexC'], - properties: { - vertexA: { - type: 'integer', - description: 'Value for vertex A', - minimum: 0, - maximum: 1000, - }, - vertexB: { - type: 'integer', - description: 'Value for vertex B', - minimum: 0, - maximum: 1000, - }, - vertexC: { - type: 'integer', - description: 'Value for vertex C', - minimum: 0, - maximum: 1000, - }, - sum: { - type: 'integer', - description: 'Sum of the values for the vertices', - const: 1000, - }, - }, - }, - ratingValue: { - type: 'object', - description: 'Value structure for a rating annotation.', - required: ['rating'], - properties: { - rating: { - type: 'integer', - description: 'The star rating value', - minimum: 1, - maximum: 10, - }, - }, - }, - singleSelectValue: { - type: 'object', - description: 'Value structure for a single-select annotation.', - required: ['option'], - properties: { - option: { - type: 'string', - description: 'The selected option', - }, - }, - }, - multiSelectValue: { - type: 'object', - description: 'Value structure for a multi-select annotation.', - required: ['options'], - properties: { - options: { - type: 'array', - description: 'The selected options', - items: { - type: 'string', - }, - }, - }, - }, - }, - }, - NetworkCosmikAnnotationField: { - lexicon: 1, - id: 'network.cosmik.annotationField', - description: 'A single record type for all annotation fields.', - defs: { - main: { - type: 'record', - description: 'A record defining an annotation field.', - key: 'tid', - record: { - type: 'object', - required: ['name', 'description', 'definition'], - properties: { - name: { - type: 'string', - description: 'Name of the annotation field', - }, - description: { - type: 'string', - description: 'Description of the annotation field', - }, - createdAt: { - type: 'string', - format: 'datetime', - description: 'Timestamp when this field was created', - }, - definition: { - type: 'union', - description: - 'The specific definition of the field, determining its type and constraints.', - refs: [ - 'lex:network.cosmik.annotationField#dyadFieldDef', - 'lex:network.cosmik.annotationField#triadFieldDef', - 'lex:network.cosmik.annotationField#ratingFieldDef', - 'lex:network.cosmik.annotationField#singleSelectFieldDef', - 'lex:network.cosmik.annotationField#multiSelectFieldDef', - ], - }, - }, - }, - }, - dyadFieldDef: { - type: 'object', - description: 'Definition structure for a dyad field.', - required: ['sideA', 'sideB'], - properties: { - sideA: { - type: 'string', - description: 'Label for side A of the dyad', - }, - sideB: { - type: 'string', - description: 'Label for side B of the dyad', - }, - }, - }, - triadFieldDef: { - type: 'object', - description: 'Definition structure for a triad field.', - required: ['vertexA', 'vertexB', 'vertexC'], - properties: { - vertexA: { - type: 'string', - description: 'Label for vertex A of the triad', - }, - vertexB: { - type: 'string', - description: 'Label for vertex B of the triad', - }, - vertexC: { - type: 'string', - description: 'Label for vertex C of the triad', - }, - }, - }, - ratingFieldDef: { - type: 'object', - description: 'Definition structure for a rating field.', - required: ['numberOfStars'], - properties: { - numberOfStars: { - type: 'integer', - description: 'Maximum number of stars for the rating', - const: 5, - }, - }, - }, - singleSelectFieldDef: { - type: 'object', - description: 'Definition structure for a single-select field.', - required: ['options'], - properties: { - options: { - type: 'array', - description: 'Available options for selection', - items: { - type: 'string', - }, - }, - }, - }, - multiSelectFieldDef: { - type: 'object', - description: 'Definition structure for a multi-select field.', - required: ['options'], - properties: { - options: { - type: 'array', - description: 'Available options for selection', - items: { - type: 'string', - }, - }, - }, - }, - }, - }, - NetworkCosmikAnnotationTemplate: { - lexicon: 1, - id: 'network.cosmik.annotationTemplate', - description: 'Annotation templates for grouping annotation fields', - defs: { - main: { - type: 'record', - description: 'A record of an annotation template', - key: 'tid', - record: { - type: 'object', - required: ['name', 'description', 'annotationFields'], - properties: { - name: { - type: 'string', - description: 'Name of the template', - }, - description: { - type: 'string', - description: 'Description of the template', - }, - annotationFields: { - type: 'array', - description: - 'List of strong references to network.cosmik.annotationField records included in this template.', - items: { - type: 'ref', - ref: 'lex:network.cosmik.annotationTemplate#annotationFieldRef', - }, - }, - createdAt: { - type: 'string', - format: 'datetime', - description: 'Timestamp when this template was created', - }, - }, - }, - }, - annotationFieldRef: { - type: 'object', - description: - 'A reference to an annotation field. Defines if the field is required in the template.', - required: ['subject'], - properties: { - subject: { - type: 'ref', - ref: 'lex:com.atproto.repo.strongRef', - }, - required: { - type: 'boolean', - }, - }, - }, - }, - }, NetworkCosmikCard: { lexicon: 1, id: 'network.cosmik.card', @@ -419,13 +96,7 @@ export const schemaDict = { urlMetadata: { type: 'object', description: 'Metadata about a URL.', - required: ['url'], properties: { - url: { - type: 'string', - format: 'uri', - description: 'The URL', - }, title: { type: 'string', description: 'Title of the page', @@ -530,7 +201,13 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: ['collection', 'card', 'addedBy'], + required: [ + 'collection', + 'card', + 'originalCard', + 'addedBy', + 'addedAt', + ], properties: { collection: { type: 'ref', @@ -539,7 +216,14 @@ export const schemaDict = { }, card: { type: 'ref', - description: 'Strong reference to the card record.', + description: + 'Strong reference to the card record in the users library.', + ref: 'lex:com.atproto.repo.strongRef', + }, + originalCard: { + type: 'ref', + description: + 'Strong reference to the original card record (may be in another library).', ref: 'lex:com.atproto.repo.strongRef', }, addedBy: { @@ -608,22 +292,22 @@ export const schemaDict = { }, }, }, -} as const satisfies Record; -export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]; -export const lexicons: Lexicons = new Lexicons(schemas); +} as const satisfies Record +export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] +export const lexicons: Lexicons = new Lexicons(schemas) export function validate( v: unknown, id: string, hash: string, requiredType: true, -): ValidationResult; +): ValidationResult export function validate( v: unknown, id: string, hash: string, requiredType?: false, -): ValidationResult; +): ValidationResult export function validate( v: unknown, id: string, @@ -637,16 +321,13 @@ export function validate( error: new ValidationError( `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, ), - }; + } } export const ids = { - NetworkCosmikAnnotation: 'network.cosmik.annotation', - NetworkCosmikAnnotationField: 'network.cosmik.annotationField', - NetworkCosmikAnnotationTemplate: 'network.cosmik.annotationTemplate', NetworkCosmikCard: 'network.cosmik.card', NetworkCosmikCollection: 'network.cosmik.collection', NetworkCosmikCollectionLink: 'network.cosmik.collectionLink', NetworkCosmikDefs: 'network.cosmik.defs', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', -} as const; +} as const diff --git a/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts b/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts index e0149a5b..eac0bc16 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts @@ -1,31 +1,31 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../../lexicons'; +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' import { type $Typed, is$typed as _is$typed, type OmitKey, -} from '../../../../util'; +} from '../../../../util' const is$typed = _is$typed, - validate = _validate; -const id = 'com.atproto.repo.strongRef'; + validate = _validate +const id = 'com.atproto.repo.strongRef' export interface Main { - $type?: 'com.atproto.repo.strongRef'; - cid: string; - uri: string; + $type?: 'com.atproto.repo.strongRef' + cid: string + uri: string } -const hashMain = 'main'; +const hashMain = 'main' export function isMain(v: V) { - return is$typed(v, id, hashMain); + return is$typed(v, id, hashMain) } export function validateMain(v: V) { - return validate
(v, id, hashMain); + return validate
(v, id, hashMain) } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotation.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotation.ts deleted file mode 100644 index 950d8924..00000000 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotation.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; -import type * as NetworkCosmikDefs from './defs.js'; -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; - -const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.annotation'; - -export interface Record { - $type: 'network.cosmik.annotation'; - /** The primary URL identifying the annotated resource. */ - url: string; - /** Optional additional identifiers for the resource. */ - additionalIdentifiers?: NetworkCosmikDefs.Identifier[]; - field: ComAtprotoRepoStrongRef.Main; - fromTemplates?: ComAtprotoRepoStrongRef.Main[]; - /** An optional user-provided note about the annotation. */ - note?: string; - value: - | $Typed - | $Typed - | $Typed - | $Typed - | $Typed - | { $type: string }; - /** Timestamp when this annotation was created (usually set by PDS). */ - createdAt?: string; - [k: string]: unknown; -} - -const hashRecord = 'main'; - -export function isRecord(v: V) { - return is$typed(v, id, hashRecord); -} - -export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); -} - -/** Value structure for a dyad annotation. */ -export interface DyadValue { - $type?: 'network.cosmik.annotation#dyadValue'; - /** Value representing the relationship between sides */ - value: number; -} - -const hashDyadValue = 'dyadValue'; - -export function isDyadValue(v: V) { - return is$typed(v, id, hashDyadValue); -} - -export function validateDyadValue(v: V) { - return validate(v, id, hashDyadValue); -} - -/** Value structure for a triad annotation. */ -export interface TriadValue { - $type?: 'network.cosmik.annotation#triadValue'; - /** Value for vertex A */ - vertexA: number; - /** Value for vertex B */ - vertexB: number; - /** Value for vertex C */ - vertexC: number; - /** Sum of the values for the vertices */ - sum?: 1000; -} - -const hashTriadValue = 'triadValue'; - -export function isTriadValue(v: V) { - return is$typed(v, id, hashTriadValue); -} - -export function validateTriadValue(v: V) { - return validate(v, id, hashTriadValue); -} - -/** Value structure for a rating annotation. */ -export interface RatingValue { - $type?: 'network.cosmik.annotation#ratingValue'; - /** The star rating value */ - rating: number; -} - -const hashRatingValue = 'ratingValue'; - -export function isRatingValue(v: V) { - return is$typed(v, id, hashRatingValue); -} - -export function validateRatingValue(v: V) { - return validate(v, id, hashRatingValue); -} - -/** Value structure for a single-select annotation. */ -export interface SingleSelectValue { - $type?: 'network.cosmik.annotation#singleSelectValue'; - /** The selected option */ - option: string; -} - -const hashSingleSelectValue = 'singleSelectValue'; - -export function isSingleSelectValue(v: V) { - return is$typed(v, id, hashSingleSelectValue); -} - -export function validateSingleSelectValue(v: V) { - return validate(v, id, hashSingleSelectValue); -} - -/** Value structure for a multi-select annotation. */ -export interface MultiSelectValue { - $type?: 'network.cosmik.annotation#multiSelectValue'; - /** The selected options */ - options: string[]; -} - -const hashMultiSelectValue = 'multiSelectValue'; - -export function isMultiSelectValue(v: V) { - return is$typed(v, id, hashMultiSelectValue); -} - -export function validateMultiSelectValue(v: V) { - return validate(v, id, hashMultiSelectValue); -} diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationField.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationField.ts deleted file mode 100644 index 39590d27..00000000 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationField.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; - -const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.annotationField'; - -export interface Record { - $type: 'network.cosmik.annotationField'; - /** Name of the annotation field */ - name: string; - /** Description of the annotation field */ - description: string; - /** Timestamp when this field was created */ - createdAt?: string; - definition: - | $Typed - | $Typed - | $Typed - | $Typed - | $Typed - | { $type: string }; - [k: string]: unknown; -} - -const hashRecord = 'main'; - -export function isRecord(v: V) { - return is$typed(v, id, hashRecord); -} - -export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); -} - -/** Definition structure for a dyad field. */ -export interface DyadFieldDef { - $type?: 'network.cosmik.annotationField#dyadFieldDef'; - /** Label for side A of the dyad */ - sideA: string; - /** Label for side B of the dyad */ - sideB: string; -} - -const hashDyadFieldDef = 'dyadFieldDef'; - -export function isDyadFieldDef(v: V) { - return is$typed(v, id, hashDyadFieldDef); -} - -export function validateDyadFieldDef(v: V) { - return validate(v, id, hashDyadFieldDef); -} - -/** Definition structure for a triad field. */ -export interface TriadFieldDef { - $type?: 'network.cosmik.annotationField#triadFieldDef'; - /** Label for vertex A of the triad */ - vertexA: string; - /** Label for vertex B of the triad */ - vertexB: string; - /** Label for vertex C of the triad */ - vertexC: string; -} - -const hashTriadFieldDef = 'triadFieldDef'; - -export function isTriadFieldDef(v: V) { - return is$typed(v, id, hashTriadFieldDef); -} - -export function validateTriadFieldDef(v: V) { - return validate(v, id, hashTriadFieldDef); -} - -/** Definition structure for a rating field. */ -export interface RatingFieldDef { - $type?: 'network.cosmik.annotationField#ratingFieldDef'; - /** Maximum number of stars for the rating */ - numberOfStars: 5; -} - -const hashRatingFieldDef = 'ratingFieldDef'; - -export function isRatingFieldDef(v: V) { - return is$typed(v, id, hashRatingFieldDef); -} - -export function validateRatingFieldDef(v: V) { - return validate(v, id, hashRatingFieldDef); -} - -/** Definition structure for a single-select field. */ -export interface SingleSelectFieldDef { - $type?: 'network.cosmik.annotationField#singleSelectFieldDef'; - /** Available options for selection */ - options: string[]; -} - -const hashSingleSelectFieldDef = 'singleSelectFieldDef'; - -export function isSingleSelectFieldDef(v: V) { - return is$typed(v, id, hashSingleSelectFieldDef); -} - -export function validateSingleSelectFieldDef(v: V) { - return validate(v, id, hashSingleSelectFieldDef); -} - -/** Definition structure for a multi-select field. */ -export interface MultiSelectFieldDef { - $type?: 'network.cosmik.annotationField#multiSelectFieldDef'; - /** Available options for selection */ - options: string[]; -} - -const hashMultiSelectFieldDef = 'multiSelectFieldDef'; - -export function isMultiSelectFieldDef(v: V) { - return is$typed(v, id, hashMultiSelectFieldDef); -} - -export function validateMultiSelectFieldDef(v: V) { - return validate(v, id, hashMultiSelectFieldDef); -} diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationTemplate.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationTemplate.ts deleted file mode 100644 index da30cc25..00000000 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/annotationTemplate.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; - -const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.annotationTemplate'; - -export interface Record { - $type: 'network.cosmik.annotationTemplate'; - /** Name of the template */ - name: string; - /** Description of the template */ - description: string; - /** List of strong references to network.cosmik.annotationField records included in this template. */ - annotationFields: AnnotationFieldRef[]; - /** Timestamp when this template was created */ - createdAt?: string; - [k: string]: unknown; -} - -const hashRecord = 'main'; - -export function isRecord(v: V) { - return is$typed(v, id, hashRecord); -} - -export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); -} - -/** A reference to an annotation field. Defines if the field is required in the template. */ -export interface AnnotationFieldRef { - $type?: 'network.cosmik.annotationTemplate#annotationFieldRef'; - subject: ComAtprotoRepoStrongRef.Main; - required?: boolean; -} - -const hashAnnotationFieldRef = 'annotationFieldRef'; - -export function isAnnotationFieldRef(v: V) { - return is$typed(v, id, hashAnnotationFieldRef); -} - -export function validateAnnotationFieldRef(v: V) { - return validate(v, id, hashAnnotationFieldRef); -} diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts index b9b9b9f2..bd2dcf98 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts @@ -1,108 +1,102 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js' const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.card'; + validate = _validate +const id = 'network.cosmik.card' export interface Record { - $type: 'network.cosmik.card'; + $type: 'network.cosmik.card' /** The type of card */ - type: 'URL' | 'NOTE' | (string & {}); - content: $Typed | $Typed | { $type: string }; + type: 'URL' | 'NOTE' | (string & {}) + content: $Typed | $Typed | { $type: string } /** Optional URL associated with the card. Required for URL cards, optional for NOTE cards. */ - url?: string; - parentCard?: ComAtprotoRepoStrongRef.Main; + url?: string + parentCard?: ComAtprotoRepoStrongRef.Main /** Timestamp when this card was created (usually set by PDS). */ - createdAt?: string; - originalCard?: ComAtprotoRepoStrongRef.Main; - [k: string]: unknown; + createdAt?: string + originalCard?: ComAtprotoRepoStrongRef.Main + [k: string]: unknown } -const hashRecord = 'main'; +const hashRecord = 'main' export function isRecord(v: V) { - return is$typed(v, id, hashRecord); + return is$typed(v, id, hashRecord) } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); + return validate(v, id, hashRecord, true) } /** Content structure for a URL card. */ export interface UrlContent { - $type?: 'network.cosmik.card#urlContent'; + $type?: 'network.cosmik.card#urlContent' /** The URL being saved */ - url: string; - metadata?: UrlMetadata; + url: string + metadata?: UrlMetadata } -const hashUrlContent = 'urlContent'; +const hashUrlContent = 'urlContent' export function isUrlContent(v: V) { - return is$typed(v, id, hashUrlContent); + return is$typed(v, id, hashUrlContent) } export function validateUrlContent(v: V) { - return validate(v, id, hashUrlContent); + return validate(v, id, hashUrlContent) } /** Content structure for a note card. */ export interface NoteContent { - $type?: 'network.cosmik.card#noteContent'; + $type?: 'network.cosmik.card#noteContent' /** The note text content */ - text: string; + text: string } -const hashNoteContent = 'noteContent'; +const hashNoteContent = 'noteContent' export function isNoteContent(v: V) { - return is$typed(v, id, hashNoteContent); + return is$typed(v, id, hashNoteContent) } export function validateNoteContent(v: V) { - return validate(v, id, hashNoteContent); + return validate(v, id, hashNoteContent) } /** Metadata about a URL. */ export interface UrlMetadata { - $type?: 'network.cosmik.card#urlMetadata'; - /** The URL */ - url: string; + $type?: 'network.cosmik.card#urlMetadata' /** Title of the page */ - title?: string; + title?: string /** Description of the page */ - description?: string; + description?: string /** Author of the content */ - author?: string; + author?: string /** When the content was published */ - publishedDate?: string; + publishedDate?: string /** Name of the site */ - siteName?: string; + siteName?: string /** URL of a representative image */ - imageUrl?: string; + imageUrl?: string /** Type of content (e.g., 'video', 'article') */ - type?: string; + type?: string /** When the metadata was retrieved */ - retrievedAt?: string; + retrievedAt?: string } -const hashUrlMetadata = 'urlMetadata'; +const hashUrlMetadata = 'urlMetadata' export function isUrlMetadata(v: V) { - return is$typed(v, id, hashUrlMetadata); + return is$typed(v, id, hashUrlMetadata) } export function validateUrlMetadata(v: V) { - return validate(v, id, hashUrlMetadata); + return validate(v, id, hashUrlMetadata) } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts index 06751324..ee696f15 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts @@ -1,42 +1,38 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.collection'; + validate = _validate +const id = 'network.cosmik.collection' export interface Record { - $type: 'network.cosmik.collection'; + $type: 'network.cosmik.collection' /** Name of the collection */ - name: string; + name: string /** Description of the collection */ - description?: string; + description?: string /** Access control for the collection */ - accessType: 'OPEN' | 'CLOSED' | (string & {}); + accessType: 'OPEN' | 'CLOSED' | (string & {}) /** List of collaborator DIDs who can add cards to closed collections */ - collaborators?: string[]; + collaborators?: string[] /** Timestamp when this collection was created (usually set by PDS). */ - createdAt?: string; + createdAt?: string /** Timestamp when this collection was last updated. */ - updatedAt?: string; - [k: string]: unknown; + updatedAt?: string + [k: string]: unknown } -const hashRecord = 'main'; +const hashRecord = 'main' export function isRecord(v: V) { - return is$typed(v, id, hashRecord); + return is$typed(v, id, hashRecord) } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); + return validate(v, id, hashRecord, true) } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts index d800c4a7..3d5d9b7e 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts @@ -1,39 +1,36 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js' const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.collectionLink'; + validate = _validate +const id = 'network.cosmik.collectionLink' export interface Record { - $type: 'network.cosmik.collectionLink'; - collection: ComAtprotoRepoStrongRef.Main; - card: ComAtprotoRepoStrongRef.Main; + $type: 'network.cosmik.collectionLink' + collection: ComAtprotoRepoStrongRef.Main + card: ComAtprotoRepoStrongRef.Main + originalCard: ComAtprotoRepoStrongRef.Main /** DID of the user who added the card to the collection */ - addedBy: string; + addedBy: string /** Timestamp when the card was added to the collection. */ - addedAt?: string; + addedAt: string /** Timestamp when this link record was created (usually set by PDS). */ - createdAt?: string; - [k: string]: unknown; + createdAt?: string + [k: string]: unknown } -const hashRecord = 'main'; +const hashRecord = 'main' export function isRecord(v: V) { - return is$typed(v, id, hashRecord); + return is$typed(v, id, hashRecord) } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); + return validate(v, id, hashRecord, true) } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts index 83f3e7c2..02cc5945 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts @@ -1,34 +1,30 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon'; -import { CID } from 'multiformats/cid'; -import { validate as _validate } from '../../../lexicons'; -import { - type $Typed, - is$typed as _is$typed, - type OmitKey, -} from '../../../util'; +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' const is$typed = _is$typed, - validate = _validate; -const id = 'network.cosmik.defs'; + validate = _validate +const id = 'network.cosmik.defs' /** Represents an identifier with a type and value. */ export interface Identifier { - $type?: 'network.cosmik.defs#identifier'; + $type?: 'network.cosmik.defs#identifier' /** The type of identifier (e.g., 'doi', 'at-uri', 'isbn'). */ - type: string; + type: string /** The identifier value. */ - value: string; + value: string } -const hashIdentifier = 'identifier'; +const hashIdentifier = 'identifier' export function isIdentifier(v: V) { - return is$typed(v, id, hashIdentifier); + return is$typed(v, id, hashIdentifier) } export function validateIdentifier(v: V) { - return validate(v, id, hashIdentifier); + return validate(v, id, hashIdentifier) } diff --git a/src/modules/atproto/infrastructure/lexicon/util.ts b/src/modules/atproto/infrastructure/lexicon/util.ts index f7e33604..816ca10c 100644 --- a/src/modules/atproto/infrastructure/lexicon/util.ts +++ b/src/modules/atproto/infrastructure/lexicon/util.ts @@ -2,21 +2,21 @@ * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult } from '@atproto/lexicon'; +import { type ValidationResult } from '@atproto/lexicon' export type OmitKey = { - [K2 in keyof T as K2 extends K ? never : K2]: T[K2]; -}; + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] +} -export type $Typed = V & { $type: T }; -export type Un$Typed = OmitKey; +export type $Typed = V & { $type: T } +export type Un$Typed = OmitKey export type $Type = Hash extends 'main' ? Id - : `${Id}#${Hash}`; + : `${Id}#${Hash}` function isObject(v: V): v is V & object { - return v != null && typeof v === 'object'; + return v != null && typeof v === 'object' } function is$type( @@ -31,7 +31,7 @@ function is$type( $type.length === id.length + 1 + hash.length && $type.charCodeAt(id.length) === 35 /* '#' */ && $type.startsWith(id) && - $type.endsWith(hash); + $type.endsWith(hash) } export type $TypedObject< @@ -39,21 +39,21 @@ export type $TypedObject< Id extends string, Hash extends string, > = V extends { - $type: $Type; + $type: $Type } ? V : V extends { $type?: string } ? V extends { $type?: infer T extends $Type } ? V & { $type: T } : never - : V & { $type: $Type }; + : V & { $type: $Type } export function is$typed( v: V, id: Id, hash: Hash, ): v is $TypedObject { - return isObject(v) && '$type' in v && is$type(v.$type, id, hash); + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) } export function maybe$typed( @@ -64,12 +64,12 @@ export function maybe$typed( return ( isObject(v) && ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) - ); + ) } -export type Validator = (v: unknown) => ValidationResult; +export type Validator = (v: unknown) => ValidationResult export type ValidatorParam = - V extends Validator ? R : never; + V extends Validator ? R : never /** * Utility function that allows to convert a "validate*" utility function into a @@ -77,6 +77,6 @@ export type ValidatorParam = */ export function asPredicate(validate: V) { return function (v: T): v is T & ValidatorParam { - return validate(v).success; - }; + return validate(v).success + } } -- 2.51.2 From 9fd372cb8b9e088b59adabe7ad4cff583934e73f Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:21:41 -0700 Subject: [PATCH 43/76] refactor: update CardMapper to match Card aggregate structure Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../atproto/infrastructure/mappers/CardMapper.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/modules/atproto/infrastructure/mappers/CardMapper.ts b/src/modules/atproto/infrastructure/mappers/CardMapper.ts index e2209703..07beb8d0 100644 --- a/src/modules/atproto/infrastructure/mappers/CardMapper.ts +++ b/src/modules/atproto/infrastructure/mappers/CardMapper.ts @@ -26,15 +26,11 @@ export class CardMapper { record.url = card.url.value; } - // Add optional original card reference - if (card.originalPublishedRecordId) { - const strongRef = new StrongRef( - card.originalPublishedRecordId.getValue(), - ); - record.originalCard = { - uri: strongRef.getValue().uri, - cid: strongRef.getValue().cid, - }; + // Add optional parent card reference + if (card.parentCardId) { + // Note: This would need the parent card's published record ID + // For now, we'll skip this until we have a way to resolve the parent card's published record + // TODO: Implement parent card reference resolution } return record; -- 2.51.2 From 13e2488448173c7c4fdd68411b65609ceb3576f8 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:31:22 -0700 Subject: [PATCH 44/76] remove url from metadata for card mapper --- src/modules/atproto/infrastructure/mappers/CardMapper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/atproto/infrastructure/mappers/CardMapper.ts b/src/modules/atproto/infrastructure/mappers/CardMapper.ts index 07beb8d0..5493f11e 100644 --- a/src/modules/atproto/infrastructure/mappers/CardMapper.ts +++ b/src/modules/atproto/infrastructure/mappers/CardMapper.ts @@ -70,7 +70,6 @@ export class CardMapper { private static mapUrlMetadata(metadata: UrlMetadataVO): $Typed { return { $type: 'network.cosmik.card#urlMetadata', - url: metadata.url, title: metadata.title, description: metadata.description, author: metadata.author, -- 2.51.2 From 45bbae2308b8fecc260f4fc5c3bcf815f081b38c Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 15:50:17 -0700 Subject: [PATCH 45/76] handle original card strong ref and parent card strong ref --- .../infrastructure/mappers/CardMapper.ts | 26 ++++++++++++++----- .../publishers/ATProtoCardPublisher.ts | 3 ++- .../cards/application/ports/ICardPublisher.ts | 1 + .../domain/services/CardLibraryService.ts | 22 ++++++++++++++++ .../cards/tests/utils/FakeCardPublisher.ts | 1 + 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/modules/atproto/infrastructure/mappers/CardMapper.ts b/src/modules/atproto/infrastructure/mappers/CardMapper.ts index 5493f11e..72a4cf33 100644 --- a/src/modules/atproto/infrastructure/mappers/CardMapper.ts +++ b/src/modules/atproto/infrastructure/mappers/CardMapper.ts @@ -9,11 +9,16 @@ import { } from '../lexicon/types/network/cosmik/card'; import { StrongRef } from '../../domain'; import { UrlMetadata as UrlMetadataVO } from 'src/modules/cards/domain/value-objects/UrlMetadata'; +import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; type CardRecordDTO = Record; export class CardMapper { - static toCreateRecordDTO(card: Card): CardRecordDTO { + static toCreateRecordDTO( + card: Card, + curatorId: CuratorId, + parentCard?: Card, + ): CardRecordDTO { const record: CardRecordDTO = { $type: 'network.cosmik.card', type: card.type.value, @@ -26,11 +31,20 @@ export class CardMapper { record.url = card.url.value; } - // Add optional parent card reference - if (card.parentCardId) { - // Note: This would need the parent card's published record ID - // For now, we'll skip this until we have a way to resolve the parent card's published record - // TODO: Implement parent card reference resolution + if (card.publishedRecordId && !curatorId.equals(card.curatorId)) { + const strongRef = new StrongRef(card.publishedRecordId.getValue()); + record.originalCard = { + uri: strongRef.getValue().uri, + cid: strongRef.getValue().cid, + }; + } + + if (card.parentCardId && parentCard && parentCard.publishedRecordId) { + const strongRef = new StrongRef(parentCard.publishedRecordId.getValue()); + record.parentCard = { + uri: strongRef.getValue().uri, + cid: strongRef.getValue().cid, + }; } return record; diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts index 0b7ac753..68f86561 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts @@ -20,9 +20,10 @@ export class ATProtoCardPublisher implements ICardPublisher { async publishCardToLibrary( card: Card, curatorId: CuratorId, + parentCard?: Card, ): Promise> { try { - const record = CardMapper.toCreateRecordDTO(card); + const record = CardMapper.toCreateRecordDTO(card, curatorId, parentCard); const curatorDidResult = DID.create(curatorId.value); if (curatorDidResult.isErr()) { diff --git a/src/modules/cards/application/ports/ICardPublisher.ts b/src/modules/cards/application/ports/ICardPublisher.ts index 0cc9f1a5..ee90cd29 100644 --- a/src/modules/cards/application/ports/ICardPublisher.ts +++ b/src/modules/cards/application/ports/ICardPublisher.ts @@ -8,6 +8,7 @@ export interface ICardPublisher { publishCardToLibrary( card: Card, curatorId: CuratorId, + parentCard?: Card, ): Promise>; unpublishCardFromLibrary( diff --git a/src/modules/cards/domain/services/CardLibraryService.ts b/src/modules/cards/domain/services/CardLibraryService.ts index 048c7d25..ff9f33cf 100644 --- a/src/modules/cards/domain/services/CardLibraryService.ts +++ b/src/modules/cards/domain/services/CardLibraryService.ts @@ -45,11 +45,33 @@ export class CardLibraryService implements DomainService { ), ); } + let parentCard: Card | undefined = undefined; + + if (card.parentCardId) { + // Ensure parent card is in the curator's library + const parentCardResult = await this.cardRepository.findById( + card.parentCardId, + ); + if (parentCardResult.isErr()) { + return err( + new CardLibraryValidationError( + `Failed to fetch parent card: ${parentCardResult.error.message}`, + ), + ); + } + const parentCardValue = parentCardResult.value; + + if (!parentCardValue) { + return err(new CardLibraryValidationError(`Parent card not found`)); + } + parentCard = parentCardValue; + } // Publish card to library const publishResult = await this.cardPublisher.publishCardToLibrary( card, curatorId, + parentCard, ); if (publishResult.isErr()) { return err( diff --git a/src/modules/cards/tests/utils/FakeCardPublisher.ts b/src/modules/cards/tests/utils/FakeCardPublisher.ts index a5516fb4..97343f0d 100644 --- a/src/modules/cards/tests/utils/FakeCardPublisher.ts +++ b/src/modules/cards/tests/utils/FakeCardPublisher.ts @@ -19,6 +19,7 @@ export class FakeCardPublisher implements ICardPublisher { async publishCardToLibrary( card: Card, curatorId: CuratorId, + parentCard?: Card, ): Promise> { if (this.shouldFail) { return err( -- 2.51.2 From cfb4fda8084297af7151b990f24f5e207e59c954 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:03:28 -0700 Subject: [PATCH 46/76] update collectionLink lexicon to make originalCardRef optional --- src/modules/atproto/infrastructure/lexicon/lexicons.ts | 8 +------- .../lexicon/types/network/cosmik/collectionLink.ts | 2 +- .../atproto/infrastructure/lexicons/collectionLink.json | 8 +------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/modules/atproto/infrastructure/lexicon/lexicons.ts b/src/modules/atproto/infrastructure/lexicon/lexicons.ts index 1972eec8..ae9f5a73 100644 --- a/src/modules/atproto/infrastructure/lexicon/lexicons.ts +++ b/src/modules/atproto/infrastructure/lexicon/lexicons.ts @@ -201,13 +201,7 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: [ - 'collection', - 'card', - 'originalCard', - 'addedBy', - 'addedAt', - ], + required: ['collection', 'card', 'addedBy', 'addedAt'], properties: { collection: { type: 'ref', diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts index 3d5d9b7e..f37b9628 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts @@ -15,7 +15,7 @@ export interface Record { $type: 'network.cosmik.collectionLink' collection: ComAtprotoRepoStrongRef.Main card: ComAtprotoRepoStrongRef.Main - originalCard: ComAtprotoRepoStrongRef.Main + originalCard?: ComAtprotoRepoStrongRef.Main /** DID of the user who added the card to the collection */ addedBy: string /** Timestamp when the card was added to the collection. */ diff --git a/src/modules/atproto/infrastructure/lexicons/collectionLink.json b/src/modules/atproto/infrastructure/lexicons/collectionLink.json index 2922023c..a7e70865 100644 --- a/src/modules/atproto/infrastructure/lexicons/collectionLink.json +++ b/src/modules/atproto/infrastructure/lexicons/collectionLink.json @@ -9,13 +9,7 @@ "key": "tid", "record": { "type": "object", - "required": [ - "collection", - "card", - "originalCard", - "addedBy", - "addedAt" - ], + "required": ["collection", "card", "addedBy", "addedAt"], "properties": { "collection": { "type": "ref", -- 2.51.2 From 2fae548edf1f2e92881d636734a1f9ac07ffcf12 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:05:21 -0700 Subject: [PATCH 47/76] only include originalCardRef if its different than the authors --- .../mappers/CollectionLinkMapper.ts | 12 +++++++----- .../publishers/ATProtoCollectionPublisher.ts | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/modules/atproto/infrastructure/mappers/CollectionLinkMapper.ts b/src/modules/atproto/infrastructure/mappers/CollectionLinkMapper.ts index 5f545ed9..8bfa9f04 100644 --- a/src/modules/atproto/infrastructure/mappers/CollectionLinkMapper.ts +++ b/src/modules/atproto/infrastructure/mappers/CollectionLinkMapper.ts @@ -9,7 +9,7 @@ export class CollectionLinkMapper { link: CardLink, collectionRecord: PublishedRecordIdProps, cardRecord: PublishedRecordIdProps, - originalCardRecord: PublishedRecordIdProps, + originalCardRecord?: PublishedRecordIdProps, ): CollectionLinkRecordDTO { return { $type: 'network.cosmik.collectionLink', @@ -21,10 +21,12 @@ export class CollectionLinkMapper { uri: cardRecord.uri, cid: cardRecord.cid, }, - originalCard: { - uri: originalCardRecord.uri, - cid: originalCardRecord.cid, - }, + originalCard: originalCardRecord + ? { + uri: originalCardRecord.uri, + cid: originalCardRecord.cid, + } + : undefined, addedBy: link.addedBy.value, addedAt: link.addedAt.toISOString(), createdAt: new Date().toISOString(), diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts index e1a73624..9e13de81 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts @@ -3,7 +3,10 @@ import { Collection } from 'src/modules/cards/domain/Collection'; import { Card } from 'src/modules/cards/domain/Card'; import { Result, ok, err } from 'src/shared/core/Result'; import { UseCaseError } from 'src/shared/core/UseCaseError'; -import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; +import { + PublishedRecordId, + PublishedRecordIdProps, +} from 'src/modules/cards/domain/value-objects/PublishedRecordId'; import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; import { CollectionMapper } from '../mappers/CollectionMapper'; import { CollectionLinkMapper } from '../mappers/CollectionLinkMapper'; @@ -150,8 +153,8 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { } // Get the original published record ID - if (!card.originalPublishedRecordId) { - return err(new Error('Card must have an original published record ID')); + if (!card.publishedRecordId) { + return err(new Error('Card must have a published record ID')); } // Find the card link in the collection @@ -163,11 +166,18 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { return err(new Error('Card is not linked to this collection')); } + let originalCardRecordId: PublishedRecordIdProps | undefined; + if ( + libraryMembership.publishedRecordId.uri !== card.publishedRecordId.uri + ) { + originalCardRecordId = card.publishedRecordId.getValue(); + } + const linkRecordDTO = CollectionLinkMapper.toCreateRecordDTO( cardLink, collection.publishedRecordId.getValue(), libraryMembership.publishedRecordId.getValue(), - card.originalPublishedRecordId.getValue(), + originalCardRecordId, ); const createResult = await agent.com.atproto.repo.createRecord({ -- 2.51.2 From f3c328e8fe18feee77375dea5ce2be328b00f2f5 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:08:09 -0700 Subject: [PATCH 48/76] include env config in publisher tests --- .../ATProtoCardPublisher.integration.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts index a565c562..c728aaac 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts @@ -6,6 +6,7 @@ import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; import dotenv from 'dotenv'; import { AppPasswordAgentService } from './AppPasswordAgentService'; import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; +import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; // Load environment variables from .env.test dotenv.config({ path: '.env.test' }); @@ -17,6 +18,7 @@ describe('ATProtoCardPublisher', () => { let publisher: ATProtoCardPublisher; let curatorId: CuratorId; let publishedCardIds: PublishedRecordId[] = []; + const envConfig: EnvironmentConfigService = new EnvironmentConfigService(); beforeAll(async () => { if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { @@ -30,7 +32,10 @@ describe('ATProtoCardPublisher', () => { password: process.env.BSKY_APP_PASSWORD, }); - publisher = new ATProtoCardPublisher(agentService); + publisher = new ATProtoCardPublisher( + agentService, + envConfig.getAtProtoConfig().collections.card, + ); curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); }); @@ -482,7 +487,10 @@ describe('ATProtoCardPublisher', () => { password: 'invalid-password', }); - const invalidPublisher = new ATProtoCardPublisher(invalidAgentService); + const invalidPublisher = new ATProtoCardPublisher( + invalidAgentService, + envConfig.getAtProtoConfig().collections.card, + ); const invalidCuratorId = CuratorId.create('did:plc:invalid').unwrap(); const testCard = new CardBuilder() -- 2.51.2 From b014505a0f5f64528721fce8c2528e5c5762b304 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:14:09 -0700 Subject: [PATCH 49/76] refactor: update test to use note card with original published record Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../ATProtoCardPublisher.integration.test.ts | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts index c728aaac..c5da41ca 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts @@ -388,48 +388,37 @@ describe('ATProtoCardPublisher', () => { }, 20000); }); - describe('Card with Original Published Record ID', () => { - it('should publish a card that references an original published record', async () => { + describe('Note Card with Original Published Record ID', () => { + it('should publish a note card that references an original published record', async () => { // Skip test if credentials are not available if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { console.warn('Skipping test: BSKY credentials not found in .env.test'); return; } - const testUrl = URL.create( - 'https://example.com/original-article', + const referenceUrl = URL.create( + 'https://example.com/referenced-article', ).unwrap(); - // Create URL metadata - const metadata = UrlMetadata.create({ - url: testUrl.value, - title: 'Original Article', - description: 'An original article that will be referenced', - author: 'Original Author', - siteName: 'Example.com', - type: 'article', - retrievedAt: new Date(), - }).unwrap(); - - // Create a card with an original published record ID (simulating a card that references another published card) + // Create a note card with an original published record ID (simulating a note that references another published card) const originalRecordId = { uri: 'at://did:plc:example/network.cosmik.card/original123', cid: 'bafyoriginal123', }; - const cardWithOriginal = new CardBuilder() + const noteCardWithOriginal = new CardBuilder() .withCuratorId(curatorId.value) - .withUrlCard(testUrl, metadata) - .withUrl(testUrl) + .withNoteCard('This is my note about the referenced article') + .withUrl(referenceUrl) // Optional URL reference .withOriginalPublishedRecordId(originalRecordId) .buildOrThrow(); // Add card to curator's library first - cardWithOriginal.addToLibrary(curatorId); + noteCardWithOriginal.addToLibrary(curatorId); // 1. Publish the card const publishResult = await publisher.publishCardToLibrary( - cardWithOriginal, + noteCardWithOriginal, curatorId, ); expect(publishResult.isOk()).toBe(true); @@ -439,24 +428,28 @@ describe('ATProtoCardPublisher', () => { publishedCardIds.push(publishedRecordId); console.log( - `Published card with original record ID: ${publishedRecordId.getValue().uri}`, + `Published note card with original record ID: ${publishedRecordId.getValue().uri}`, ); // Verify the card has the original published record ID - expect(cardWithOriginal.originalPublishedRecordId).toBeDefined(); - expect(cardWithOriginal.originalPublishedRecordId!.getValue().uri).toBe( + expect(noteCardWithOriginal.originalPublishedRecordId).toBeDefined(); + expect(noteCardWithOriginal.originalPublishedRecordId!.getValue().uri).toBe( originalRecordId.uri, ); - expect(cardWithOriginal.originalPublishedRecordId!.getValue().cid).toBe( + expect(noteCardWithOriginal.originalPublishedRecordId!.getValue().cid).toBe( originalRecordId.cid, ); + // Verify it's a note card with optional URL + expect(noteCardWithOriginal.isNoteCard).toBe(true); + expect(noteCardWithOriginal.url).toBe(referenceUrl); + // Mark card as published in library - cardWithOriginal.markCardInLibraryAsPublished( + noteCardWithOriginal.markCardInLibraryAsPublished( curatorId, publishedRecordId, ); - expect(cardWithOriginal.isInLibrary(curatorId)).toBe(true); + expect(noteCardWithOriginal.isInLibrary(curatorId)).toBe(true); // 2. Unpublish the card if (UNPUBLISH) { @@ -466,7 +459,7 @@ describe('ATProtoCardPublisher', () => { ); expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished card with original record ID'); + console.log('Successfully unpublished note card with original record ID'); // Remove from cleanup list since we've already unpublished it publishedCardIds = publishedCardIds.filter( -- 2.51.2 From 9026ef18bfd2175cb3762a7b2b57c8a6d787a212 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:48:56 -0700 Subject: [PATCH 50/76] update card builder --- src/modules/cards/tests/utils/builders/CardBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/cards/tests/utils/builders/CardBuilder.ts b/src/modules/cards/tests/utils/builders/CardBuilder.ts index 2d9fb85e..d9b27bd2 100644 --- a/src/modules/cards/tests/utils/builders/CardBuilder.ts +++ b/src/modules/cards/tests/utils/builders/CardBuilder.ts @@ -64,7 +64,7 @@ export class CardBuilder { return this; } - withOriginalPublishedRecordId(originalPublishedRecordId: { + withPublishedRecordId(originalPublishedRecordId: { uri: string; cid: string; }): CardBuilder { -- 2.51.2 From 4431c3ec49e7ddebcfe1c7bc84c7228c51839763 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:49:37 -0700 Subject: [PATCH 51/76] overwrite record type (to show dev component if not prod) --- .../atproto/infrastructure/publishers/ATProtoCardPublisher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts index 68f86561..8e5a681d 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts @@ -23,7 +23,9 @@ export class ATProtoCardPublisher implements ICardPublisher { parentCard?: Card, ): Promise> { try { - const record = CardMapper.toCreateRecordDTO(card, curatorId, parentCard); + let record = CardMapper.toCreateRecordDTO(card, curatorId, parentCard); + record.$type = this.cardCollection as any; + const curatorDidResult = DID.create(curatorId.value); if (curatorDidResult.isErr()) { -- 2.51.2 From 6be9c69a263d99286a9ad5b2e49f0dff4f5530ef Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:49:58 -0700 Subject: [PATCH 52/76] update card publisher tests --- .../ATProtoCardPublisher.integration.test.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts index c5da41ca..63a28164 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts @@ -312,13 +312,13 @@ describe('ATProtoCardPublisher', () => { .withUrl(parentUrl) // Optional: include the same URL as reference .buildOrThrow(); - // Add note card to curator's library noteCard.addToLibrary(curatorId); // 3. Publish the note card const notePublishResult = await publisher.publishCardToLibrary( noteCard, curatorId, + parentUrlCard, ); expect(notePublishResult.isOk()).toBe(true); @@ -406,14 +406,20 @@ describe('ATProtoCardPublisher', () => { cid: 'bafyoriginal123', }; + const curatorId2 = CuratorId.create('did:plc:defaultCurator2').unwrap(); const noteCardWithOriginal = new CardBuilder() - .withCuratorId(curatorId.value) + .withCuratorId(curatorId2.value) .withNoteCard('This is my note about the referenced article') .withUrl(referenceUrl) // Optional URL reference - .withOriginalPublishedRecordId(originalRecordId) + .withPublishedRecordId(originalRecordId) .buildOrThrow(); // Add card to curator's library first + noteCardWithOriginal.addToLibrary(curatorId2); + noteCardWithOriginal.markCardInLibraryAsPublished( + curatorId2, + PublishedRecordId.create(originalRecordId), + ); noteCardWithOriginal.addToLibrary(curatorId); // 1. Publish the card @@ -432,11 +438,11 @@ describe('ATProtoCardPublisher', () => { ); // Verify the card has the original published record ID - expect(noteCardWithOriginal.originalPublishedRecordId).toBeDefined(); - expect(noteCardWithOriginal.originalPublishedRecordId!.getValue().uri).toBe( + expect(noteCardWithOriginal.publishedRecordId).toBeDefined(); + expect(noteCardWithOriginal.publishedRecordId!.getValue().uri).toBe( originalRecordId.uri, ); - expect(noteCardWithOriginal.originalPublishedRecordId!.getValue().cid).toBe( + expect(noteCardWithOriginal.publishedRecordId!.getValue().cid).toBe( originalRecordId.cid, ); @@ -459,7 +465,9 @@ describe('ATProtoCardPublisher', () => { ); expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished note card with original record ID'); + console.log( + 'Successfully unpublished note card with original record ID', + ); // Remove from cleanup list since we've already unpublished it publishedCardIds = publishedCardIds.filter( -- 2.51.2 From 4d19967de5e4e627c9090bc9c01eef8da1c5d8bf Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:52:59 -0700 Subject: [PATCH 53/76] update collection publisher test --- ...otoCollectionPublisher.integration.test.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts index 1d4b4f97..66aeedb7 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts @@ -3,16 +3,13 @@ import { FakeCardPublisher } from 'src/modules/cards/tests/utils/FakeCardPublish import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; import { CollectionBuilder } from 'src/modules/cards/tests/utils/builders/CollectionBuilder'; import { CardBuilder } from 'src/modules/cards/tests/utils/builders/CardBuilder'; -import { - Collection, - CollectionAccessType, -} from 'src/modules/cards/domain/Collection'; -import { Card } from 'src/modules/cards/domain/Card'; +import { CollectionAccessType } from 'src/modules/cards/domain/Collection'; import { URL } from 'src/modules/cards/domain/value-objects/URL'; import { UrlMetadata } from 'src/modules/cards/domain/value-objects/UrlMetadata'; import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; import dotenv from 'dotenv'; import { AppPasswordAgentService } from './AppPasswordAgentService'; +import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; // Load environment variables from .env.test dotenv.config({ path: '.env.test' }); @@ -23,6 +20,7 @@ describe('ATProtoCollectionPublisher', () => { let curatorId: CuratorId; let publishedCollectionIds: PublishedRecordId[] = []; let publishedLinkIds: PublishedRecordId[] = []; + const envConfig: EnvironmentConfigService = new EnvironmentConfigService(); beforeAll(async () => { if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { @@ -36,7 +34,11 @@ describe('ATProtoCollectionPublisher', () => { password: process.env.BSKY_APP_PASSWORD, }); - collectionPublisher = new ATProtoCollectionPublisher(agentService); + collectionPublisher = new ATProtoCollectionPublisher( + agentService, + envConfig.getAtProtoConfig().collections.collection, + envConfig.getAtProtoConfig().collections.collectionLink, + ); cardPublisher = new FakeCardPublisher(); curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); }); @@ -148,7 +150,7 @@ describe('ATProtoCollectionPublisher', () => { .withCuratorId(curatorId.value) .withUrlCard(testUrl1, metadata1) .withUrl(testUrl1) - .withOriginalPublishedRecordId({ + .withPublishedRecordId({ uri: 'at://did:plc:original/network.cosmik.card/original1', cid: 'bafyoriginal1', }) @@ -166,7 +168,7 @@ describe('ATProtoCollectionPublisher', () => { .withCuratorId(curatorId.value) .withUrlCard(testUrl2, metadata2) .withUrl(testUrl2) - .withOriginalPublishedRecordId({ + .withPublishedRecordId({ uri: 'at://did:plc:original/network.cosmik.card/original2', cid: 'bafyoriginal2', }) @@ -266,7 +268,7 @@ describe('ATProtoCollectionPublisher', () => { .withCuratorId(curatorId.value) .withUrlCard(testUrl3, metadata3) .withUrl(testUrl3) - .withOriginalPublishedRecordId({ + .withPublishedRecordId({ uri: 'at://did:plc:original/network.cosmik.card/original3', cid: 'bafyoriginal3', }) @@ -379,6 +381,8 @@ describe('ATProtoCollectionPublisher', () => { const invalidPublisher = new ATProtoCollectionPublisher( invalidAgentService, + envConfig.getAtProtoConfig().collections.collection, + envConfig.getAtProtoConfig().collections.collectionLink, ); const testCollection = new CollectionBuilder() -- 2.51.2 From 9febb07793458fea79cac7607e3f502ea63a3e06 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 16:58:53 -0700 Subject: [PATCH 54/76] feat: add cross-user card sharing test for collection publishing Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- ...otoCollectionPublisher.integration.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts index 66aeedb7..df8c11de 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts @@ -315,6 +315,129 @@ describe('ATProtoCollectionPublisher', () => { ); } }, 30000); + + it('should publish a collection with a card shared between users', async () => { + // Skip test if credentials are not available + if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { + console.warn('Skipping test: BSKY credentials not found in .env.test'); + return; + } + + // Create two different users (we'll simulate userB with the same credentials for testing) + const userA = CuratorId.create('did:plc:userA').unwrap(); + const userB = curatorId; // Use our test credentials as userB + + // 1. User A creates and publishes a note card to their library + const originalNote = new CardBuilder() + .withCuratorId(userA.value) + .withNoteCard('This is an original note created by User A') + .withPublishedRecordId({ + uri: 'at://did:plc:userA/network.cosmik.card/original-note', + cid: 'bafyuserAnote123', + }) + .buildOrThrow(); + + // Simulate User A adding to their own library and publishing + originalNote.addToLibrary(userA); + const userALibraryRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:userA/network.cosmik.card/userA-library-note', + cid: 'bafyuserAlib123', + }); + originalNote.markCardInLibraryAsPublished(userA, userALibraryRecordId); + + console.log( + `Simulated User A publishing note: ${originalNote.publishedRecordId?.getValue().uri}`, + ); + + // 2. User B adds the same card to their library + const addToLibraryResult = originalNote.addToLibrary(userB); + expect(addToLibraryResult.isOk()).toBe(true); + + // User B publishes the card to their library (this creates a new record in User B's repo) + const userBPublishResult = await cardPublisher.publishCardToLibrary( + originalNote, + userB, + ); + expect(userBPublishResult.isOk()).toBe(true); + const userBLibraryRecordId = userBPublishResult.unwrap(); + originalNote.markCardInLibraryAsPublished(userB, userBLibraryRecordId); + + console.log( + `User B published note to their library: ${userBLibraryRecordId.getValue().uri}`, + ); + + // 3. User B creates a collection + const userBCollection = new CollectionBuilder() + .withAuthorId(userB.value) + .withName('User B Collection with Shared Card') + .withDescription('Collection containing a card originally created by User A') + .withAccessType(CollectionAccessType.OPEN) + .buildOrThrow(); + + // 4. User B adds the card to their collection + const addCardResult = userBCollection.addCard(originalNote.cardId, userB); + expect(addCardResult.isOk()).toBe(true); + + // 5. Publish User B's collection + const collectionPublishResult = await collectionPublisher.publish(userBCollection); + expect(collectionPublishResult.isOk()).toBe(true); + + if (collectionPublishResult.isOk()) { + const collectionRecordId = collectionPublishResult.value; + publishedCollectionIds.push(collectionRecordId); + userBCollection.markAsPublished(collectionRecordId); + + console.log( + `Published User B's collection: ${collectionRecordId.getValue().uri}`, + ); + + // 6. Publish the collection link (this should reference both User B's copy and User A's original) + const linkPublishResult = + await collectionPublisher.publishCardAddedToCollection( + originalNote, + userBCollection, + userB, + ); + expect(linkPublishResult.isOk()).toBe(true); + + if (linkPublishResult.isOk()) { + const linkRecordId = linkPublishResult.value; + publishedLinkIds.push(linkRecordId); + userBCollection.markCardLinkAsPublished(originalNote.cardId, linkRecordId); + + console.log( + `Published collection link with cross-user card reference: ${linkRecordId.getValue().uri}`, + ); + + // Verify the card has different library memberships + expect(originalNote.libraryMembershipCount).toBe(2); + expect(originalNote.isInLibrary(userA)).toBe(true); + expect(originalNote.isInLibrary(userB)).toBe(true); + + // Verify User B's library membership has a different published record ID than the original + const userBMembership = originalNote.getLibraryInfo(userB); + expect(userBMembership).toBeDefined(); + expect(userBMembership!.publishedRecordId).toBeDefined(); + expect(userBMembership!.publishedRecordId!.uri).not.toBe( + originalNote.publishedRecordId!.uri, + ); + + console.log( + `Verified cross-user card sharing: Original=${originalNote.publishedRecordId!.uri}, UserB=${userBMembership!.publishedRecordId!.uri}`, + ); + } + + // 7. Clean up + const unpublishResult = await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); + + console.log('Successfully unpublished collection with shared card'); + + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } + }, 30000); }); describe.skip('Closed Collection Publishing', () => { -- 2.51.2 From c14f1fbf1561ba5c5fa0eec0ad7683eb403478e1 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 17:12:45 -0700 Subject: [PATCH 55/76] feat: add optional UNPUBLISH flag to collection publisher test Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- ...otoCollectionPublisher.integration.test.ts | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts index df8c11de..2fe89143 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts @@ -14,6 +14,9 @@ import { EnvironmentConfigService } from 'src/shared/infrastructure/config/Envir // Load environment variables from .env.test dotenv.config({ path: '.env.test' }); +// Set to false to skip unpublishing (useful for debugging published records) +const UNPUBLISH = false; + describe('ATProtoCollectionPublisher', () => { let collectionPublisher: ATProtoCollectionPublisher; let cardPublisher: FakeCardPublisher; @@ -44,8 +47,11 @@ describe('ATProtoCollectionPublisher', () => { }); afterAll(async () => { - // Skip cleanup if credentials are not available - if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { + // Skip cleanup if credentials are not available or UNPUBLISH is false + if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD || !UNPUBLISH) { + if (!UNPUBLISH) { + console.log('Skipping cleanup: UNPUBLISH is set to false'); + } return; } @@ -117,16 +123,20 @@ describe('ATProtoCollectionPublisher', () => { } // 3. Unpublish the collection - const unpublishResult = - await collectionPublisher.unpublish(collectionRecordId); - expect(unpublishResult.isOk()).toBe(true); + if (UNPUBLISH) { + const unpublishResult = + await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished empty collection'); + console.log('Successfully unpublished empty collection'); - // Remove from cleanup list since we've already unpublished it - publishedCollectionIds = publishedCollectionIds.filter( - (id) => id !== collectionRecordId, - ); + // Remove from cleanup list since we've already unpublished it + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } else { + console.log('Skipping unpublish: UNPUBLISH is set to false'); + } } }, 15000); @@ -303,16 +313,20 @@ describe('ATProtoCollectionPublisher', () => { ); // 6. Unpublish the collection - const unpublishResult = - await collectionPublisher.unpublish(collectionRecordId); - expect(unpublishResult.isOk()).toBe(true); + if (UNPUBLISH) { + const unpublishResult = + await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished collection with cards'); + console.log('Successfully unpublished collection with cards'); - // Remove from cleanup list since we've already unpublished it - publishedCollectionIds = publishedCollectionIds.filter( - (id) => id !== collectionRecordId, - ); + // Remove from cleanup list since we've already unpublished it + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } else { + console.log('Skipping unpublish: UNPUBLISH is set to false'); + } } }, 30000); @@ -428,14 +442,18 @@ describe('ATProtoCollectionPublisher', () => { } // 7. Clean up - const unpublishResult = await collectionPublisher.unpublish(collectionRecordId); - expect(unpublishResult.isOk()).toBe(true); + if (UNPUBLISH) { + const unpublishResult = await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished collection with shared card'); + console.log('Successfully unpublished collection with shared card'); - publishedCollectionIds = publishedCollectionIds.filter( - (id) => id !== collectionRecordId, - ); + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } else { + console.log('Skipping unpublish: UNPUBLISH is set to false'); + } } }, 30000); }); @@ -480,16 +498,20 @@ describe('ATProtoCollectionPublisher', () => { expect(collection.collaboratorIds[0]?.value).toBe(collaboratorDid); // 2. Unpublish the collection - const unpublishResult = - await collectionPublisher.unpublish(collectionRecordId); - expect(unpublishResult.isOk()).toBe(true); + if (UNPUBLISH) { + const unpublishResult = + await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); - console.log('Successfully unpublished closed collection'); + console.log('Successfully unpublished closed collection'); - // Remove from cleanup list since we've already unpublished it - publishedCollectionIds = publishedCollectionIds.filter( - (id) => id !== collectionRecordId, - ); + // Remove from cleanup list since we've already unpublished it + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } else { + console.log('Skipping unpublish: UNPUBLISH is set to false'); + } } }, 15000); }); @@ -595,12 +617,16 @@ describe('ATProtoCollectionPublisher', () => { } // Clean up - const unpublishResult = - await collectionPublisher.unpublish(collectionRecordId); - expect(unpublishResult.isOk()).toBe(true); - publishedCollectionIds = publishedCollectionIds.filter( - (id) => id !== collectionRecordId, - ); + if (UNPUBLISH) { + const unpublishResult = + await collectionPublisher.unpublish(collectionRecordId); + expect(unpublishResult.isOk()).toBe(true); + publishedCollectionIds = publishedCollectionIds.filter( + (id) => id !== collectionRecordId, + ); + } else { + console.log('Skipping unpublish: UNPUBLISH is set to false'); + } } }, 15000); }); -- 2.51.2 From 2b12512fd1ef80e65def213f03490fae0f111c15 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 17:16:32 -0700 Subject: [PATCH 56/76] overwrite record type in collection publisher --- .../infrastructure/publishers/ATProtoCollectionPublisher.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts index 9e13de81..6545cc11 100644 --- a/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts +++ b/src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts @@ -58,6 +58,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { // Update existing collection record const collectionRecordDTO = CollectionMapper.toCreateRecordDTO(collection); + collectionRecordDTO.$type = this.collectionCollection as any; const publishedRecordId = collection.publishedRecordId.getValue(); const strongRef = new StrongRef(publishedRecordId); @@ -76,6 +77,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { // Create new collection record const collectionRecordDTO = CollectionMapper.toCreateRecordDTO(collection); + collectionRecordDTO.$type = this.collectionCollection as any; const createResult = await agent.com.atproto.repo.createRecord({ repo: curatorDid.value, @@ -179,6 +181,7 @@ export class ATProtoCollectionPublisher implements ICollectionPublisher { libraryMembership.publishedRecordId.getValue(), originalCardRecordId, ); + linkRecordDTO.$type = this.collectionLinkCollection as any; const createResult = await agent.com.atproto.repo.createRecord({ repo: curatorDid.value, -- 2.51.2 From c68e40fd3e552501444a27aa04351ef3041972fb Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 7 Oct 2025 17:16:39 -0700 Subject: [PATCH 57/76] formatting in tests --- ...ATProtoCollectionPublisher.integration.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts index 2fe89143..f56f8336 100644 --- a/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts +++ b/src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts @@ -384,7 +384,9 @@ describe('ATProtoCollectionPublisher', () => { const userBCollection = new CollectionBuilder() .withAuthorId(userB.value) .withName('User B Collection with Shared Card') - .withDescription('Collection containing a card originally created by User A') + .withDescription( + 'Collection containing a card originally created by User A', + ) .withAccessType(CollectionAccessType.OPEN) .buildOrThrow(); @@ -393,7 +395,8 @@ describe('ATProtoCollectionPublisher', () => { expect(addCardResult.isOk()).toBe(true); // 5. Publish User B's collection - const collectionPublishResult = await collectionPublisher.publish(userBCollection); + const collectionPublishResult = + await collectionPublisher.publish(userBCollection); expect(collectionPublishResult.isOk()).toBe(true); if (collectionPublishResult.isOk()) { @@ -417,7 +420,10 @@ describe('ATProtoCollectionPublisher', () => { if (linkPublishResult.isOk()) { const linkRecordId = linkPublishResult.value; publishedLinkIds.push(linkRecordId); - userBCollection.markCardLinkAsPublished(originalNote.cardId, linkRecordId); + userBCollection.markCardLinkAsPublished( + originalNote.cardId, + linkRecordId, + ); console.log( `Published collection link with cross-user card reference: ${linkRecordId.getValue().uri}`, @@ -443,7 +449,8 @@ describe('ATProtoCollectionPublisher', () => { // 7. Clean up if (UNPUBLISH) { - const unpublishResult = await collectionPublisher.unpublish(collectionRecordId); + const unpublishResult = + await collectionPublisher.unpublish(collectionRecordId); expect(unpublishResult.isOk()).toBe(true); console.log('Successfully unpublished collection with shared card'); -- 2.51.2 From a44c306cc51fc68754353f58a5ee8b7ec8fe4a2e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 14:06:08 -0700 Subject: [PATCH 58/76] add custom migration to clear db --- .../migrations/0005_truncate-tables.sql | 10 + .../migrations/meta/0005_snapshot.json | 767 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + 3 files changed, 784 insertions(+) create mode 100644 src/shared/infrastructure/database/migrations/0005_truncate-tables.sql create mode 100644 src/shared/infrastructure/database/migrations/meta/0005_snapshot.json diff --git a/src/shared/infrastructure/database/migrations/0005_truncate-tables.sql b/src/shared/infrastructure/database/migrations/0005_truncate-tables.sql new file mode 100644 index 00000000..684f8a97 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0005_truncate-tables.sql @@ -0,0 +1,10 @@ +-- Custom SQL migration file, put your code below! -- +-- Truncate all tables to wipe data but keep table structure +-- CASCADE ensures foreign key constraints don't prevent truncation +TRUNCATE TABLE "collection_cards", +"collection_collaborators", +"library_memberships", +"collections", +"cards", +"published_records", +"feed_activities" CASCADE; \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..ebbfb9f6 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json @@ -0,0 +1,767 @@ +{ + "id": "2d3157fe-869f-46ed-bcdc-03fc757f16c1", + "prevId": "1e15b3e8-04ba-4e0a-965e-07142597ddc4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "columnsFrom": [ + "parent_card_id" + ], + "tableTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "columnsFrom": [ + "published_record_id" + ], + "tableTo": "published_records", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "columnsFrom": [ + "collection_id" + ], + "tableTo": "collections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "columnsFrom": [ + "card_id" + ], + "tableTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "columnsFrom": [ + "published_record_id" + ], + "tableTo": "published_records", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "columnsFrom": [ + "collection_id" + ], + "tableTo": "collections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "columnsFrom": [ + "published_record_id" + ], + "tableTo": "published_records", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "columnsFrom": [ + "card_id" + ], + "tableTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "columnsFrom": [ + "published_record_id" + ], + "tableTo": "published_records", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": [ + "card_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "columnsFrom": [ + "user_did" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index eaee835e..44a7988e 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1759875361391, "tag": "0004_brainy_rocket_racer", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1759957391430, + "tag": "0005_truncate-tables", + "breakpoints": true } ] } \ No newline at end of file -- 2.51.2 From 3b298cbd6b49bfde53ea99da196bf2d429addb3b Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 14:44:52 -0700 Subject: [PATCH 59/76] update feature guide context --- docs/features/GUIDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/features/GUIDE.md b/docs/features/GUIDE.md index 9ae4749b..16b39b4c 100644 --- a/docs/features/GUIDE.md +++ b/docs/features/GUIDE.md @@ -346,4 +346,5 @@ src/shared/infrastructure/http/factories/ServiceFactory.ts src/shared/infrastructure/http/factories/UseCaseFactory.ts src/modules/cards/domain/Card.ts src/modules/cards/domain/events/CardAddedToLibraryEvent.ts +src/modules/cards/infrastructure/http/routes/index.ts ``` -- 2.51.2 From 522d8ff5c4007187d98bafc87cfd36498b2f1455 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 14:55:47 -0700 Subject: [PATCH 60/76] feat: implement GetUrlStatusForMyLibraryUseCase for checking URL card status Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.ts | 115 ++++++++++++++++++ .../GetUrlStatusForMyLibraryController.ts | 40 ++++++ .../infrastructure/http/routes/cardRoutes.ts | 7 ++ .../cards/infrastructure/http/routes/index.ts | 3 + src/shared/infrastructure/http/app.ts | 1 + .../http/factories/ControllerFactory.ts | 6 + .../http/factories/UseCaseFactory.ts | 7 ++ 7 files changed, 179 insertions(+) create mode 100644 src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts create mode 100644 src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts new file mode 100644 index 00000000..da33d774 --- /dev/null +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -0,0 +1,115 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { BaseUseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; +import { ICardRepository } from '../../../domain/ICardRepository'; +import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; +import { CuratorId } from '../../../domain/value-objects/CuratorId'; +import { URL } from '../../../domain/value-objects/URL'; + +export interface GetUrlStatusForMyLibraryQuery { + url: string; + curatorId: string; +} + +export interface CollectionInfo { + id: string; + uri: string; + name: string; + description: string; +} + +export interface GetUrlStatusForMyLibraryResult { + cardId?: string; + collections?: CollectionInfo[]; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< + GetUrlStatusForMyLibraryQuery, + Result +> { + constructor( + private cardRepository: ICardRepository, + private collectionQueryRepository: ICollectionQueryRepository, + eventPublisher: IEventPublisher, + ) { + super(eventPublisher); + } + + async execute( + query: GetUrlStatusForMyLibraryQuery, + ): Promise< + Result< + GetUrlStatusForMyLibraryResult, + ValidationError | AppError.UnexpectedError + > + > { + try { + // Validate and create CuratorId + const curatorIdResult = CuratorId.create(query.curatorId); + if (curatorIdResult.isErr()) { + return err( + new ValidationError( + `Invalid curator ID: ${curatorIdResult.error.message}`, + ), + ); + } + const curatorId = curatorIdResult.value; + + // Validate URL + const urlResult = URL.create(query.url); + if (urlResult.isErr()) { + return err( + new ValidationError(`Invalid URL: ${urlResult.error.message}`), + ); + } + const url = urlResult.value; + + // Check if user has a URL card with this URL + const existingCardResult = await this.cardRepository.findUsersUrlCardByUrl( + url, + curatorId, + ); + if (existingCardResult.isErr()) { + return err(AppError.UnexpectedError.create(existingCardResult.error)); + } + + const card = existingCardResult.value; + const result: GetUrlStatusForMyLibraryResult = {}; + + if (card) { + result.cardId = card.cardId.getStringValue(); + + // TODO: Need to extend ICollectionQueryRepository with method to get collections containing a specific card for a user + // For now, we'll note this limitation + // const collectionsResult = await this.collectionQueryRepository.getCollectionsContainingCardForUser( + // card.cardId, + // curatorId, + // ); + // if (collectionsResult.isErr()) { + // return err(AppError.UnexpectedError.create(collectionsResult.error)); + // } + // result.collections = collectionsResult.value.map(collection => ({ + // id: collection.id, + // uri: collection.uri, + // name: collection.name, + // description: collection.description, + // })); + + // Placeholder for collections - will need repository extension + result.collections = []; + } + + return ok(result); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts b/src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts new file mode 100644 index 00000000..c008b445 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts @@ -0,0 +1,40 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetUrlStatusForMyLibraryUseCase } from '../../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class GetUrlStatusForMyLibraryController extends Controller { + constructor( + private getUrlStatusForMyLibraryUseCase: GetUrlStatusForMyLibraryUseCase, + ) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { url } = req.query; + const curatorId = req.did; + + if (!curatorId) { + return this.unauthorized(res); + } + + if (!url || typeof url !== 'string') { + return this.badRequest(res, 'URL query parameter is required'); + } + + const result = await this.getUrlStatusForMyLibraryUseCase.execute({ + url, + curatorId, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/cards/infrastructure/http/routes/cardRoutes.ts b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts index 766d0daf..8c7634ce 100644 --- a/src/modules/cards/infrastructure/http/routes/cardRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts @@ -10,6 +10,7 @@ import { GetUrlCardViewController } from '../controllers/GetUrlCardViewControlle import { GetLibrariesForCardController } from '../controllers/GetLibrariesForCardController'; import { GetMyUrlCardsController } from '../controllers/GetMyUrlCardsController'; import { GetUserUrlCardsController } from '../controllers/GetUserUrlCardsController'; +import { GetUrlStatusForMyLibraryController } from '../controllers/GetUrlStatusForMyLibraryController'; import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; export function createCardRoutes( @@ -25,6 +26,7 @@ export function createCardRoutes( getLibrariesForCardController: GetLibrariesForCardController, getMyUrlCardsController: GetMyUrlCardsController, getUserUrlCardsController: GetUserUrlCardsController, + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, ): Router { const router = Router(); @@ -39,6 +41,11 @@ export function createCardRoutes( getMyUrlCardsController.execute(req, res), ); + // GET /api/cards/library/status - Get URL status for my library + router.get('/library/status', authMiddleware.ensureAuthenticated(), (req, res) => + getUrlStatusForMyLibraryController.execute(req, res), + ); + // GET /api/cards/user/:identifier - Get user's URL cards by identifier router.get('/user/:identifier', authMiddleware.optionalAuth(), (req, res) => getUserUrlCardsController.execute(req, res), diff --git a/src/modules/cards/infrastructure/http/routes/index.ts b/src/modules/cards/infrastructure/http/routes/index.ts index 1d9a0687..96181ffe 100644 --- a/src/modules/cards/infrastructure/http/routes/index.ts +++ b/src/modules/cards/infrastructure/http/routes/index.ts @@ -12,6 +12,7 @@ import { GetUrlCardViewController } from '../controllers/GetUrlCardViewControlle import { GetLibrariesForCardController } from '../controllers/GetLibrariesForCardController'; import { GetMyUrlCardsController } from '../controllers/GetMyUrlCardsController'; import { GetUserUrlCardsController } from '../controllers/GetUserUrlCardsController'; +import { GetUrlStatusForMyLibraryController } from '../controllers/GetUrlStatusForMyLibraryController'; import { CreateCollectionController } from '../controllers/CreateCollectionController'; import { UpdateCollectionController } from '../controllers/UpdateCollectionController'; import { DeleteCollectionController } from '../controllers/DeleteCollectionController'; @@ -35,6 +36,7 @@ export function createCardsModuleRoutes( getLibrariesForCardController: GetLibrariesForCardController, getMyUrlCardsController: GetMyUrlCardsController, getUserUrlCardsController: GetUserUrlCardsController, + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, // Collection controllers createCollectionController: CreateCollectionController, updateCollectionController: UpdateCollectionController, @@ -62,6 +64,7 @@ export function createCardsModuleRoutes( getLibrariesForCardController, getMyUrlCardsController, getUserUrlCardsController, + getUrlStatusForMyLibraryController, ), ); diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 622be264..ed9ab952 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -67,6 +67,7 @@ export const createExpressApp = ( controllers.getLibrariesForCardController, controllers.getMyUrlCardsController, controllers.getUserUrlCardsController, + controllers.getUrlStatusForMyLibraryController, // Collection controllers controllers.createCollectionController, controllers.updateCollectionController, diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index bfbd11c8..1fc15105 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -26,6 +26,7 @@ import { LogoutController } from 'src/modules/user/infrastructure/http/controlle import { GenerateExtensionTokensController } from 'src/modules/user/infrastructure/http/controllers/GenerateExtensionTokensController'; import { GetUserCollectionsController } from 'src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController'; import { GetCollectionPageByAtUriController } from 'src/modules/cards/infrastructure/http/controllers/GetCollectionPageByAtUriController'; +import { GetUrlStatusForMyLibraryController } from '../../../../modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController'; export interface Controllers { // User controllers @@ -56,6 +57,7 @@ export interface Controllers { getCollectionPageByAtUriController: GetCollectionPageByAtUriController; getMyCollectionsController: GetMyCollectionsController; getCollectionsController: GetUserCollectionsController; + getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController; // Feed controllers getGlobalFeedController: GetGlobalFeedController; } @@ -144,6 +146,10 @@ export class ControllerFactory { getCollectionsController: new GetUserCollectionsController( useCases.getCollectionsUseCase, ), + getUrlStatusForMyLibraryController: + new GetUrlStatusForMyLibraryController( + useCases.getUrlStatusForMyLibraryUseCase, + ), // Feed controllers getGlobalFeedController: new GetGlobalFeedController( diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index e98a4df3..5934a80d 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -25,6 +25,7 @@ import { GetGlobalFeedUseCase } from '../../../../modules/feeds/application/useC import { AddActivityToFeedUseCase } from '../../../../modules/feeds/application/useCases/commands/AddActivityToFeedUseCase'; import { GetCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionsUseCase'; import { GetCollectionPageByAtUriUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase'; +import { GetUrlStatusForMyLibraryUseCase } from '../../../../modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; export interface UseCases { // User use cases @@ -52,6 +53,7 @@ export interface UseCases { getCollectionPageUseCase: GetCollectionPageUseCase; getCollectionPageByAtUriUseCase: GetCollectionPageByAtUriUseCase; getCollectionsUseCase: GetCollectionsUseCase; + getUrlStatusForMyLibraryUseCase: GetUrlStatusForMyLibraryUseCase; // Feed use cases getGlobalFeedUseCase: GetGlobalFeedUseCase; addActivityToFeedUseCase: AddActivityToFeedUseCase; @@ -170,6 +172,11 @@ export class UseCaseFactory { services.profileService, services.identityResolutionService, ), + getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( + repositories.cardRepository, + repositories.collectionQueryRepository, + services.eventPublisher, + ), // Feed use cases getGlobalFeedUseCase: new GetGlobalFeedUseCase( -- 2.51.2 From ff1fa33c9100f6f76df9af6f3d05378002ff2223 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 14:59:42 -0700 Subject: [PATCH 61/76] feat: add method to get collections containing a specific card for a user Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.ts | 48 +++++++++---------- .../domain/ICollectionQueryRepository.ts | 13 +++++ .../InMemoryCollectionQueryRepository.ts | 41 +++++++++++++++- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index da33d774..407e6ab3 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -15,9 +15,9 @@ export interface GetUrlStatusForMyLibraryQuery { export interface CollectionInfo { id: string; - uri: string; + uri?: string; name: string; - description: string; + description?: string; } export interface GetUrlStatusForMyLibraryResult { @@ -33,7 +33,10 @@ export class ValidationError extends UseCaseError { export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< GetUrlStatusForMyLibraryQuery, - Result + Result< + GetUrlStatusForMyLibraryResult, + ValidationError | AppError.UnexpectedError + > > { constructor( private cardRepository: ICardRepository, @@ -73,10 +76,8 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< const url = urlResult.value; // Check if user has a URL card with this URL - const existingCardResult = await this.cardRepository.findUsersUrlCardByUrl( - url, - curatorId, - ); + const existingCardResult = + await this.cardRepository.findUsersUrlCardByUrl(url, curatorId); if (existingCardResult.isErr()) { return err(AppError.UnexpectedError.create(existingCardResult.error)); } @@ -87,24 +88,23 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< if (card) { result.cardId = card.cardId.getStringValue(); - // TODO: Need to extend ICollectionQueryRepository with method to get collections containing a specific card for a user - // For now, we'll note this limitation - // const collectionsResult = await this.collectionQueryRepository.getCollectionsContainingCardForUser( - // card.cardId, - // curatorId, - // ); - // if (collectionsResult.isErr()) { - // return err(AppError.UnexpectedError.create(collectionsResult.error)); - // } - // result.collections = collectionsResult.value.map(collection => ({ - // id: collection.id, - // uri: collection.uri, - // name: collection.name, - // description: collection.description, - // })); + // Get collections containing this card for the user + try { + const collections = + await this.collectionQueryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); - // Placeholder for collections - will need repository extension - result.collections = []; + result.collections = collections.map((collection) => ({ + id: collection.id, + uri: collection.uri, + name: collection.name, + description: collection.description, + })); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } } return ok(result); diff --git a/src/modules/cards/domain/ICollectionQueryRepository.ts b/src/modules/cards/domain/ICollectionQueryRepository.ts index 830f8581..fe36974c 100644 --- a/src/modules/cards/domain/ICollectionQueryRepository.ts +++ b/src/modules/cards/domain/ICollectionQueryRepository.ts @@ -36,9 +36,22 @@ export interface CollectionQueryResultDTO { authorId: string; // Just the curator ID, not enriched data } +// View data for collections containing a specific card +export interface CollectionContainingCardDTO { + id: string; + uri?: string; + name: string; + description?: string; +} + export interface ICollectionQueryRepository { findByCreator( curatorId: string, options: CollectionQueryOptions, ): Promise>; + + getCollectionsContainingCardForUser( + cardId: string, + curatorId: string, + ): Promise; } diff --git a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts index 8cacb1fd..ce28e729 100644 --- a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts @@ -1,8 +1,8 @@ -import { Result, ok, err } from '../../../../shared/core/Result'; import { ICollectionQueryRepository, CollectionQueryOptions, CollectionQueryResultDTO, + CollectionContainingCardDTO, PaginatedQueryResult, CollectionSortField, SortOrder, @@ -116,6 +116,45 @@ export class InMemoryCollectionQueryRepository return sorted; } + async getCollectionsContainingCardForUser( + cardId: string, + curatorId: string, + ): Promise { + try { + // Get all collections and filter by creator + const allCollections = this.collectionRepository.getAllCollections(); + const creatorCollections = allCollections.filter( + (collection) => collection.authorId.value === curatorId, + ); + + // Filter collections that contain the specified card + const collectionsWithCard = creatorCollections.filter((collection) => + collection.cardLinks.some( + (link) => link.cardId.getStringValue() === cardId, + ), + ); + + // Transform to DTOs + const result: CollectionContainingCardDTO[] = collectionsWithCard.map( + (collection) => { + const collectionPublishedRecordId = collection.publishedRecordId; + return { + id: collection.collectionId.getStringValue(), + uri: collectionPublishedRecordId?.uri, + name: collection.name.value, + description: collection.description?.value, + }; + }, + ); + + return result; + } catch (error) { + throw new Error( + `Failed to get collections containing card: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + clear(): void { // No separate state to clear } -- 2.51.2 From 12c07d033c150148d2fd001ab2d176b63ea98c63 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:08:19 -0700 Subject: [PATCH 62/76] test: add comprehensive test suite for GetUrlStatusForMyLibraryUseCase Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts new file mode 100644 index 00000000..98c89487 --- /dev/null +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -0,0 +1,499 @@ +import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; +import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; +import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; +import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; +import { FakeCardPublisher } from '../utils/FakeCardPublisher'; +import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; +import { FakeEventPublisher } from '../utils/FakeEventPublisher'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { CardBuilder } from '../utils/builders/CardBuilder'; +import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; +import { CardTypeEnum } from '../../domain/value-objects/CardType'; +import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; + +describe('GetUrlStatusForMyLibraryUseCase', () => { + let useCase: GetUrlStatusForMyLibraryUseCase; + let cardRepository: InMemoryCardRepository; + let collectionRepository: InMemoryCollectionRepository; + let collectionQueryRepository: InMemoryCollectionQueryRepository; + let cardPublisher: FakeCardPublisher; + let collectionPublisher: FakeCollectionPublisher; + let eventPublisher: FakeEventPublisher; + let curatorId: CuratorId; + let otherCuratorId: CuratorId; + + beforeEach(() => { + cardRepository = new InMemoryCardRepository(); + collectionRepository = new InMemoryCollectionRepository(); + collectionQueryRepository = new InMemoryCollectionQueryRepository(collectionRepository); + cardPublisher = new FakeCardPublisher(); + collectionPublisher = new FakeCollectionPublisher(); + eventPublisher = new FakeEventPublisher(); + + useCase = new GetUrlStatusForMyLibraryUseCase( + cardRepository, + collectionQueryRepository, + eventPublisher, + ); + + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); + }); + + afterEach(() => { + cardRepository.clear(); + collectionRepository.clear(); + collectionQueryRepository.clear(); + cardPublisher.clear(); + collectionPublisher.clear(); + eventPublisher.clear(); + }); + + describe('URL card in collections', () => { + it('should return card ID and collections when user has URL card in multiple collections', async () => { + const testUrl = 'https://example.com/test-article'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Publish the card to simulate it being published + cardPublisher.publishCard(card); + + // Create first collection + const collection1 = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('Tech Articles') + .withDescription('Collection of technology articles') + .build(); + + if (collection1 instanceof Error) { + throw new Error(`Failed to create collection1: ${collection1.message}`); + } + + // Create second collection + const collection2 = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('Reading List') + .withDescription('My personal reading list') + .build(); + + if (collection2 instanceof Error) { + throw new Error(`Failed to create collection2: ${collection2.message}`); + } + + // Add card to both collections + const addToCollection1Result = collection1.addCard(card.cardId, curatorId); + if (addToCollection1Result.isErr()) { + throw new Error(`Failed to add card to collection1: ${addToCollection1Result.error.message}`); + } + + const addToCollection2Result = collection2.addCard(card.cardId, curatorId); + if (addToCollection2Result.isErr()) { + throw new Error(`Failed to add card to collection2: ${addToCollection2Result.error.message}`); + } + + // Mark collections as published + const collection1PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', + cid: 'bafyreicollection1cid', + }).unwrap(); + + const collection2PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', + cid: 'bafyreicollection2cid', + }).unwrap(); + + collection1.markAsPublished(collection1PublishedRecordId); + collection2.markAsPublished(collection2PublishedRecordId); + + // Mark card links as published in collections + const cardLinkPublishedRecordId1 = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', + cid: 'bafyreilink1cid', + }).unwrap(); + + const cardLinkPublishedRecordId2 = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', + cid: 'bafyreilink2cid', + }).unwrap(); + + collection1.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId1); + collection2.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId2); + + // Save collections + await collectionRepository.save(collection1); + await collectionRepository.save(collection2); + + // Publish collections and links + collectionPublisher.publishCollection(collection1); + collectionPublisher.publishCollection(collection2); + collectionPublisher.publishCardLink(collection1.collectionId.getStringValue(), { + cardId: card.cardId.getStringValue(), + publishedRecordId: cardLinkPublishedRecordId1, + }); + collectionPublisher.publishCardLink(collection2.collectionId.getStringValue(), { + cardId: card.cardId.getStringValue(), + publishedRecordId: cardLinkPublishedRecordId2, + }); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(2); + + // Verify collection details + const techArticlesCollection = response.collections?.find(c => c.name === 'Tech Articles'); + const readingListCollection = response.collections?.find(c => c.name === 'Reading List'); + + expect(techArticlesCollection).toBeDefined(); + expect(techArticlesCollection?.id).toBe(collection1.collectionId.getStringValue()); + expect(techArticlesCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection1'); + expect(techArticlesCollection?.name).toBe('Tech Articles'); + expect(techArticlesCollection?.description).toBe('Collection of technology articles'); + + expect(readingListCollection).toBeDefined(); + expect(readingListCollection?.id).toBe(collection2.collectionId.getStringValue()); + expect(readingListCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection2'); + expect(readingListCollection?.name).toBe('Reading List'); + expect(readingListCollection?.description).toBe('My personal reading list'); + }); + + it('should return card ID and empty collections when user has URL card but not in any collections', async () => { + const testUrl = 'https://example.com/standalone-article'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Publish the card + cardPublisher.publishCard(card); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(0); + }); + + it('should return empty result when user does not have URL card for the URL', async () => { + const testUrl = 'https://example.com/nonexistent-article'; + + // Execute the use case without creating any cards + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBeUndefined(); + expect(response.collections).toBeUndefined(); + }); + + it('should not return collections from other users even if they have the same URL', async () => { + const testUrl = 'https://example.com/shared-article'; + + // Create URL card for first user + const card1 = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card1 instanceof Error) { + throw new Error(`Failed to create card1: ${card1.message}`); + } + + const addToLibResult1 = card1.addToLibrary(curatorId); + if (addToLibResult1.isErr()) { + throw new Error(`Failed to add card1 to library: ${addToLibResult1.error.message}`); + } + + await cardRepository.save(card1); + + // Create URL card for second user (different card, same URL) + const card2 = new CardBuilder() + .withCuratorId(otherCuratorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card2 instanceof Error) { + throw new Error(`Failed to create card2: ${card2.message}`); + } + + const addToLibResult2 = card2.addToLibrary(otherCuratorId); + if (addToLibResult2.isErr()) { + throw new Error(`Failed to add card2 to library: ${addToLibResult2.error.message}`); + } + + await cardRepository.save(card2); + + // Create collection for second user and add their card + const otherUserCollection = new CollectionBuilder() + .withAuthorId(otherCuratorId.value) + .withName('Other User Collection') + .build(); + + if (otherUserCollection instanceof Error) { + throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + } + + const addToOtherCollectionResult = otherUserCollection.addCard(card2.cardId, otherCuratorId); + if (addToOtherCollectionResult.isErr()) { + throw new Error(`Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`); + } + + await collectionRepository.save(otherUserCollection); + + // Execute the use case for first user + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result - should only see first user's card, no collections + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card1.cardId.getStringValue()); + expect(response.collections).toHaveLength(0); // No collections for first user + }); + + it('should only return collections owned by the requesting user', async () => { + const testUrl = 'https://example.com/multi-user-article'; + + // Create URL card for the user + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Create user's own collection + const userCollection = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('My Collection') + .build(); + + if (userCollection instanceof Error) { + throw new Error(`Failed to create user collection: ${userCollection.message}`); + } + + const addToUserCollectionResult = userCollection.addCard(card.cardId, curatorId); + if (addToUserCollectionResult.isErr()) { + throw new Error(`Failed to add card to user collection: ${addToUserCollectionResult.error.message}`); + } + + await collectionRepository.save(userCollection); + + // Create another user's collection (this should not appear in results) + const otherUserCollection = new CollectionBuilder() + .withAuthorId(otherCuratorId.value) + .withName('Other User Collection') + .build(); + + if (otherUserCollection instanceof Error) { + throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + } + + // Note: We don't add the card to the other user's collection since they can't add + // another user's card to their collection in this domain model + + await collectionRepository.save(otherUserCollection); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result - should only see user's own collection + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(1); + expect(response.collections?.[0]?.name).toBe('My Collection'); + expect(response.collections?.[0]?.id).toBe(userCollection.collectionId.getStringValue()); + }); + }); + + describe('Validation', () => { + it('should fail with invalid URL', async () => { + const query = { + url: 'not-a-valid-url', + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Invalid URL'); + } + }); + + it('should fail with invalid curator ID', async () => { + const query = { + url: 'https://example.com/valid-url', + curatorId: 'invalid-curator-id', + }; + + const result = await useCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Invalid curator ID'); + } + }); + }); + + describe('Error handling', () => { + it('should handle repository errors gracefully', async () => { + // Create a mock repository that throws an error + const errorCardRepository = { + findUsersUrlCardByUrl: jest.fn().mockRejectedValue(new Error('Database error')), + save: jest.fn(), + findById: jest.fn(), + delete: jest.fn(), + findByUrl: jest.fn(), + findByCuratorId: jest.fn(), + findByParentCardId: jest.fn(), + }; + + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( + errorCardRepository, + collectionQueryRepository, + eventPublisher, + ); + + const query = { + url: 'https://example.com/test-url', + curatorId: curatorId.value, + }; + + const result = await errorUseCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Database error'); + } + }); + + it('should handle collection query repository errors gracefully', async () => { + const testUrl = 'https://example.com/error-test'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Create a mock collection query repository that throws an error + const errorCollectionQueryRepository = { + findByCreator: jest.fn(), + getCollectionsContainingCardForUser: jest.fn().mockRejectedValue(new Error('Collection query error')), + }; + + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( + cardRepository, + errorCollectionQueryRepository, + eventPublisher, + ); + + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await errorUseCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Collection query error'); + } + }); + }); +}); -- 2.51.2 From 421c4f432333585b3ec68f857167dfc1cf28add6 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:11:45 -0700 Subject: [PATCH 63/76] refactor: update GetUrlStatusForMyLibraryUseCase tests with correct dependencies and method names Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 98c89487..d9228558 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -10,6 +10,7 @@ import { CardBuilder } from '../utils/builders/CardBuilder'; import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; +import { URL } from '../../domain/value-objects/URL'; describe('GetUrlStatusForMyLibraryUseCase', () => { let useCase: GetUrlStatusForMyLibraryUseCase; @@ -54,10 +55,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const testUrl = 'https://example.com/test-article'; // Create a URL card + const url = URL.create(testUrl).unwrap(); const card = new CardBuilder() .withCuratorId(curatorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card instanceof Error) { @@ -73,7 +75,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { await cardRepository.save(card); // Publish the card to simulate it being published - cardPublisher.publishCard(card); + cardPublisher.publishCardToLibrary(card, curatorId); // Create first collection const collection1 = new CollectionBuilder() @@ -112,12 +114,12 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const collection1PublishedRecordId = PublishedRecordId.create({ uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', cid: 'bafyreicollection1cid', - }).unwrap(); + }); const collection2PublishedRecordId = PublishedRecordId.create({ uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', cid: 'bafyreicollection2cid', - }).unwrap(); + }); collection1.markAsPublished(collection1PublishedRecordId); collection2.markAsPublished(collection2PublishedRecordId); @@ -126,12 +128,12 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const cardLinkPublishedRecordId1 = PublishedRecordId.create({ uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', cid: 'bafyreilink1cid', - }).unwrap(); + }); const cardLinkPublishedRecordId2 = PublishedRecordId.create({ uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', cid: 'bafyreilink2cid', - }).unwrap(); + }); collection1.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId1); collection2.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId2); @@ -141,16 +143,10 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { await collectionRepository.save(collection2); // Publish collections and links - collectionPublisher.publishCollection(collection1); - collectionPublisher.publishCollection(collection2); - collectionPublisher.publishCardLink(collection1.collectionId.getStringValue(), { - cardId: card.cardId.getStringValue(), - publishedRecordId: cardLinkPublishedRecordId1, - }); - collectionPublisher.publishCardLink(collection2.collectionId.getStringValue(), { - cardId: card.cardId.getStringValue(), - publishedRecordId: cardLinkPublishedRecordId2, - }); + collectionPublisher.publish(collection1); + collectionPublisher.publish(collection2); + collectionPublisher.publishCardAddedToCollection(card, collection1, curatorId); + collectionPublisher.publishCardAddedToCollection(card, collection2, curatorId); // Execute the use case const query = { @@ -188,10 +184,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const testUrl = 'https://example.com/standalone-article'; // Create a URL card + const url = URL.create(testUrl).unwrap(); const card = new CardBuilder() .withCuratorId(curatorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card instanceof Error) { @@ -207,7 +204,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { await cardRepository.save(card); // Publish the card - cardPublisher.publishCard(card); + cardPublisher.publishCardToLibrary(card, curatorId); // Execute the use case const query = { @@ -248,10 +245,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const testUrl = 'https://example.com/shared-article'; // Create URL card for first user + const url = URL.create(testUrl).unwrap(); const card1 = new CardBuilder() .withCuratorId(curatorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card1 instanceof Error) { @@ -269,7 +267,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const card2 = new CardBuilder() .withCuratorId(otherCuratorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card2 instanceof Error) { @@ -320,10 +318,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const testUrl = 'https://example.com/multi-user-article'; // Create URL card for the user + const url = URL.create(testUrl).unwrap(); const card = new CardBuilder() .withCuratorId(curatorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card instanceof Error) { @@ -454,10 +453,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const testUrl = 'https://example.com/error-test'; // Create a URL card + const url = URL.create(testUrl).unwrap(); const card = new CardBuilder() .withCuratorId(curatorId.value) .withType(CardTypeEnum.URL) - .withUrl(testUrl) + .withUrl(url) .build(); if (card instanceof Error) { -- 2.51.2 From 72041e4af61814d36f3de0ae616324d7d11ad360 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:16:43 -0700 Subject: [PATCH 64/76] fix: resolve card repository query and test error handling issues Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 145 ++++++++++++++---- .../tests/utils/InMemoryCardRepository.ts | 4 +- 2 files changed, 113 insertions(+), 36 deletions(-) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index d9228558..b02c41e3 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -11,6 +11,7 @@ import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; import { URL } from '../../domain/value-objects/URL'; +import { err } from 'src/shared/core/Result'; describe('GetUrlStatusForMyLibraryUseCase', () => { let useCase: GetUrlStatusForMyLibraryUseCase; @@ -26,7 +27,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { beforeEach(() => { cardRepository = new InMemoryCardRepository(); collectionRepository = new InMemoryCollectionRepository(); - collectionQueryRepository = new InMemoryCollectionQueryRepository(collectionRepository); + collectionQueryRepository = new InMemoryCollectionQueryRepository( + collectionRepository, + ); cardPublisher = new FakeCardPublisher(); collectionPublisher = new FakeCollectionPublisher(); eventPublisher = new FakeEventPublisher(); @@ -69,7 +72,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { // Add card to library const addToLibResult = card.addToLibrary(curatorId); if (addToLibResult.isErr()) { - throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + throw new Error( + `Failed to add card to library: ${addToLibResult.error.message}`, + ); } await cardRepository.save(card); @@ -100,14 +105,24 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { } // Add card to both collections - const addToCollection1Result = collection1.addCard(card.cardId, curatorId); + const addToCollection1Result = collection1.addCard( + card.cardId, + curatorId, + ); if (addToCollection1Result.isErr()) { - throw new Error(`Failed to add card to collection1: ${addToCollection1Result.error.message}`); + throw new Error( + `Failed to add card to collection1: ${addToCollection1Result.error.message}`, + ); } - const addToCollection2Result = collection2.addCard(card.cardId, curatorId); + const addToCollection2Result = collection2.addCard( + card.cardId, + curatorId, + ); if (addToCollection2Result.isErr()) { - throw new Error(`Failed to add card to collection2: ${addToCollection2Result.error.message}`); + throw new Error( + `Failed to add card to collection2: ${addToCollection2Result.error.message}`, + ); } // Mark collections as published @@ -135,8 +150,14 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { cid: 'bafyreilink2cid', }); - collection1.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId1); - collection2.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId2); + collection1.markCardLinkAsPublished( + card.cardId, + cardLinkPublishedRecordId1, + ); + collection2.markCardLinkAsPublished( + card.cardId, + cardLinkPublishedRecordId2, + ); // Save collections await collectionRepository.save(collection1); @@ -145,8 +166,16 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { // Publish collections and links collectionPublisher.publish(collection1); collectionPublisher.publish(collection2); - collectionPublisher.publishCardAddedToCollection(card, collection1, curatorId); - collectionPublisher.publishCardAddedToCollection(card, collection2, curatorId); + collectionPublisher.publishCardAddedToCollection( + card, + collection1, + curatorId, + ); + collectionPublisher.publishCardAddedToCollection( + card, + collection2, + curatorId, + ); // Execute the use case const query = { @@ -164,20 +193,36 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(response.collections).toHaveLength(2); // Verify collection details - const techArticlesCollection = response.collections?.find(c => c.name === 'Tech Articles'); - const readingListCollection = response.collections?.find(c => c.name === 'Reading List'); + const techArticlesCollection = response.collections?.find( + (c) => c.name === 'Tech Articles', + ); + const readingListCollection = response.collections?.find( + (c) => c.name === 'Reading List', + ); expect(techArticlesCollection).toBeDefined(); - expect(techArticlesCollection?.id).toBe(collection1.collectionId.getStringValue()); - expect(techArticlesCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection1'); + expect(techArticlesCollection?.id).toBe( + collection1.collectionId.getStringValue(), + ); + expect(techArticlesCollection?.uri).toBe( + 'at://did:plc:testcurator/network.cosmik.collection/collection1', + ); expect(techArticlesCollection?.name).toBe('Tech Articles'); - expect(techArticlesCollection?.description).toBe('Collection of technology articles'); + expect(techArticlesCollection?.description).toBe( + 'Collection of technology articles', + ); expect(readingListCollection).toBeDefined(); - expect(readingListCollection?.id).toBe(collection2.collectionId.getStringValue()); - expect(readingListCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection2'); + expect(readingListCollection?.id).toBe( + collection2.collectionId.getStringValue(), + ); + expect(readingListCollection?.uri).toBe( + 'at://did:plc:testcurator/network.cosmik.collection/collection2', + ); expect(readingListCollection?.name).toBe('Reading List'); - expect(readingListCollection?.description).toBe('My personal reading list'); + expect(readingListCollection?.description).toBe( + 'My personal reading list', + ); }); it('should return card ID and empty collections when user has URL card but not in any collections', async () => { @@ -198,7 +243,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { // Add card to library const addToLibResult = card.addToLibrary(curatorId); if (addToLibResult.isErr()) { - throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + throw new Error( + `Failed to add card to library: ${addToLibResult.error.message}`, + ); } await cardRepository.save(card); @@ -258,7 +305,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const addToLibResult1 = card1.addToLibrary(curatorId); if (addToLibResult1.isErr()) { - throw new Error(`Failed to add card1 to library: ${addToLibResult1.error.message}`); + throw new Error( + `Failed to add card1 to library: ${addToLibResult1.error.message}`, + ); } await cardRepository.save(card1); @@ -276,7 +325,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const addToLibResult2 = card2.addToLibrary(otherCuratorId); if (addToLibResult2.isErr()) { - throw new Error(`Failed to add card2 to library: ${addToLibResult2.error.message}`); + throw new Error( + `Failed to add card2 to library: ${addToLibResult2.error.message}`, + ); } await cardRepository.save(card2); @@ -288,12 +339,19 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { .build(); if (otherUserCollection instanceof Error) { - throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + throw new Error( + `Failed to create other user collection: ${otherUserCollection.message}`, + ); } - const addToOtherCollectionResult = otherUserCollection.addCard(card2.cardId, otherCuratorId); + const addToOtherCollectionResult = otherUserCollection.addCard( + card2.cardId, + otherCuratorId, + ); if (addToOtherCollectionResult.isErr()) { - throw new Error(`Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`); + throw new Error( + `Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`, + ); } await collectionRepository.save(otherUserCollection); @@ -331,7 +389,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const addToLibResult = card.addToLibrary(curatorId); if (addToLibResult.isErr()) { - throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + throw new Error( + `Failed to add card to library: ${addToLibResult.error.message}`, + ); } await cardRepository.save(card); @@ -343,12 +403,19 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { .build(); if (userCollection instanceof Error) { - throw new Error(`Failed to create user collection: ${userCollection.message}`); + throw new Error( + `Failed to create user collection: ${userCollection.message}`, + ); } - const addToUserCollectionResult = userCollection.addCard(card.cardId, curatorId); + const addToUserCollectionResult = userCollection.addCard( + card.cardId, + curatorId, + ); if (addToUserCollectionResult.isErr()) { - throw new Error(`Failed to add card to user collection: ${addToUserCollectionResult.error.message}`); + throw new Error( + `Failed to add card to user collection: ${addToUserCollectionResult.error.message}`, + ); } await collectionRepository.save(userCollection); @@ -360,7 +427,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { .build(); if (otherUserCollection instanceof Error) { - throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + throw new Error( + `Failed to create other user collection: ${otherUserCollection.message}`, + ); } // Note: We don't add the card to the other user's collection since they can't add @@ -383,7 +452,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(response.cardId).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(1); expect(response.collections?.[0]?.name).toBe('My Collection'); - expect(response.collections?.[0]?.id).toBe(userCollection.collectionId.getStringValue()); + expect(response.collections?.[0]?.id).toBe( + userCollection.collectionId.getStringValue(), + ); }); }); @@ -419,9 +490,11 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { describe('Error handling', () => { it('should handle repository errors gracefully', async () => { - // Create a mock repository that throws an error + // Create a mock repository that returns an error Result const errorCardRepository = { - findUsersUrlCardByUrl: jest.fn().mockRejectedValue(new Error('Database error')), + findUsersUrlCardByUrl: jest + .fn() + .mockResolvedValue(err(new Error('Database error'))), save: jest.fn(), findById: jest.fn(), delete: jest.fn(), @@ -466,7 +539,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const addToLibResult = card.addToLibrary(curatorId); if (addToLibResult.isErr()) { - throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + throw new Error( + `Failed to add card to library: ${addToLibResult.error.message}`, + ); } await cardRepository.save(card); @@ -474,7 +549,9 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { // Create a mock collection query repository that throws an error const errorCollectionQueryRepository = { findByCreator: jest.fn(), - getCollectionsContainingCardForUser: jest.fn().mockRejectedValue(new Error('Collection query error')), + getCollectionsContainingCardForUser: jest + .fn() + .mockRejectedValue(new Error('Collection query error')), }; const errorUseCase = new GetUrlStatusForMyLibraryUseCase( diff --git a/src/modules/cards/tests/utils/InMemoryCardRepository.ts b/src/modules/cards/tests/utils/InMemoryCardRepository.ts index ccf98180..cdae40a8 100644 --- a/src/modules/cards/tests/utils/InMemoryCardRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardRepository.ts @@ -54,8 +54,8 @@ export class InMemoryCardRepository implements ICardRepository { try { const card = Array.from(this.cards.values()).find( (card) => - card.content.type === 'URL' && - card.content.urlContent?.url.value === url.value && + card.type.value === 'URL' && + card.url?.value === url.value && card.props.curatorId.equals(curatorId), ); return ok(card ? this.clone(card) : null); -- 2.51.2 From db4e7c2c84c7130f907917d5473a5355005759e8 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:24:18 -0700 Subject: [PATCH 65/76] feat: implement `getCollectionsContainingCardForUser` method in DrizzleCollectionQueryRepository Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../DrizzleCollectionQueryRepository.ts | 47 +- ...sContainingCardForUser.integration.test.ts | 766 ++++++++++++++++++ 2 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts index 877abb0e..a3378f96 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts @@ -1,4 +1,4 @@ -import { eq, desc, asc, count, sql, or, ilike } from 'drizzle-orm'; +import { eq, desc, asc, count, sql, or, ilike, and } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { ICollectionQueryRepository, @@ -7,8 +7,9 @@ import { CollectionQueryResultDTO, CollectionSortField, SortOrder, + CollectionContainingCardDTO, } from '../../domain/ICollectionQueryRepository'; -import { collections } from './schema/collection.sql'; +import { collections, collectionCards } from './schema/collection.sql'; import { publishedRecords } from './schema/publishedRecord.sql'; import { CollectionMapper } from './mappers/CollectionMapper'; @@ -108,6 +109,48 @@ export class DrizzleCollectionQueryRepository } } + async getCollectionsContainingCardForUser( + cardId: string, + curatorId: string, + ): Promise { + try { + // Find collections authored by this curator that contain this card + const collectionResults = await this.db + .select({ + id: collections.id, + name: collections.name, + description: collections.description, + uri: publishedRecords.uri, + }) + .from(collections) + .leftJoin( + publishedRecords, + eq(collections.publishedRecordId, publishedRecords.id), + ) + .innerJoin( + collectionCards, + eq(collections.id, collectionCards.collectionId), + ) + .where( + and( + eq(collections.authorId, curatorId), + eq(collectionCards.cardId, cardId), + ), + ) + .orderBy(asc(collections.name)); + + return collectionResults.map((result) => ({ + id: result.id, + uri: result.uri || undefined, + name: result.name, + description: result.description || undefined, + })); + } catch (error) { + console.error('Error in getCollectionsContainingCardForUser:', error); + throw error; + } + } + private getSortColumn(sortBy: CollectionSortField) { switch (sortBy) { case CollectionSortField.NAME: diff --git a/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts new file mode 100644 index 00000000..c22675d7 --- /dev/null +++ b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts @@ -0,0 +1,766 @@ +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql'; +import postgres from 'postgres'; +import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository'; +import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; +import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { + collections, + collectionCollaborators, + collectionCards, +} from '../../infrastructure/repositories/schema/collection.sql'; +import { cards } from '../../infrastructure/repositories/schema/card.sql'; +import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; +import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; +import { Collection, CollectionAccessType } from '../../domain/Collection'; +import { CardFactory } from '../../domain/CardFactory'; +import { CardTypeEnum } from '../../domain/value-objects/CardType'; +import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; +import { URL } from '../../domain/value-objects/URL'; +import { createTestSchema } from '../test-utils/createTestSchema'; + +describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser', () => { + let container: StartedPostgreSqlContainer; + let db: PostgresJsDatabase; + let queryRepository: DrizzleCollectionQueryRepository; + let collectionRepository: DrizzleCollectionRepository; + let cardRepository: DrizzleCardRepository; + + // Test data + let curatorId: CuratorId; + let otherCuratorId: CuratorId; + + // Setup before all tests + beforeAll(async () => { + // Start PostgreSQL container + container = await new PostgreSqlContainer('postgres:14').start(); + + // Create database connection + const connectionString = container.getConnectionUri(); + process.env.DATABASE_URL = connectionString; + const client = postgres(connectionString); + db = drizzle(client); + + // Create repositories + queryRepository = new DrizzleCollectionQueryRepository(db); + collectionRepository = new DrizzleCollectionRepository(db); + cardRepository = new DrizzleCardRepository(db); + + // Create schema using helper function + await createTestSchema(db); + + // Create test data + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); + }, 60000); // Increase timeout for container startup + + // Cleanup after all tests + afterAll(async () => { + // Stop container + await container.stop(); + }); + + // Clear data between tests + beforeEach(async () => { + await db.delete(collectionCards); + await db.delete(collectionCollaborators); + await db.delete(collections); + await db.delete(libraryMemberships); + await db.delete(cards); + await db.delete(publishedRecords); + }); + + describe('URL card in collections', () => { + it('should return collections when user has URL card in multiple collections', async () => { + const testUrl = 'https://example.com/test-article'; + + // Create a URL card + const url = URL.create(testUrl).unwrap(); + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: url.value, + }, + }).unwrap(); + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + expect(addToLibResult.isOk()).toBe(true); + + await cardRepository.save(card); + + // Create first collection + const collection1 = Collection.create( + { + authorId: curatorId, + name: 'Tech Articles', + description: 'Collection of technology articles', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Create second collection + const collection2 = Collection.create( + { + authorId: curatorId, + name: 'Reading List', + description: 'My personal reading list', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Add card to both collections + const addToCollection1Result = collection1.addCard(card.cardId, curatorId); + expect(addToCollection1Result.isOk()).toBe(true); + + const addToCollection2Result = collection2.addCard(card.cardId, curatorId); + expect(addToCollection2Result.isOk()).toBe(true); + + // Mark collections as published + const collection1PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', + cid: 'bafyreicollection1cid', + }); + + const collection2PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', + cid: 'bafyreicollection2cid', + }); + + collection1.markAsPublished(collection1PublishedRecordId); + collection2.markAsPublished(collection2PublishedRecordId); + + // Save collections + await collectionRepository.save(collection1); + await collectionRepository.save(collection2); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(2); + + // Sort by name for consistent testing + result.sort((a, b) => a.name.localeCompare(b.name)); + + // Verify collection details + expect(result[0]?.id).toBe(collection2.collectionId.getStringValue()); // Reading List comes first alphabetically + expect(result[0]?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection2'); + expect(result[0]?.name).toBe('Reading List'); + expect(result[0]?.description).toBe('My personal reading list'); + + expect(result[1]?.id).toBe(collection1.collectionId.getStringValue()); // Tech Articles comes second + expect(result[1]?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection1'); + expect(result[1]?.name).toBe('Tech Articles'); + expect(result[1]?.description).toBe('Collection of technology articles'); + }); + + it('should return empty array when user has URL card but not in any collections', async () => { + const testUrl = 'https://example.com/standalone-article'; + + // Create a URL card + const url = URL.create(testUrl).unwrap(); + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: url.value, + }, + }).unwrap(); + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + expect(addToLibResult.isOk()).toBe(true); + + await cardRepository.save(card); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(0); + }); + + it('should return empty array when card does not exist', async () => { + const nonExistentCardId = new UniqueEntityID().toString(); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + nonExistentCardId, + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(0); + }); + + it('should not return collections from other users even if they have the same card', async () => { + const testUrl = 'https://example.com/shared-article'; + + // Create URL card for first user + const url = URL.create(testUrl).unwrap(); + const card1 = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: url.value, + }, + }).unwrap(); + + const addToLibResult1 = card1.addToLibrary(curatorId); + expect(addToLibResult1.isOk()).toBe(true); + + await cardRepository.save(card1); + + // Create URL card for second user (different card, same URL) + const card2 = CardFactory.create({ + curatorId: otherCuratorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: url.value, + }, + }).unwrap(); + + const addToLibResult2 = card2.addToLibrary(otherCuratorId); + expect(addToLibResult2.isOk()).toBe(true); + + await cardRepository.save(card2); + + // Create collection for second user and add their card + const otherUserCollection = Collection.create( + { + authorId: otherCuratorId, + name: 'Other User Collection', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + const addToOtherCollectionResult = otherUserCollection.addCard( + card2.cardId, + otherCuratorId, + ); + expect(addToOtherCollectionResult.isOk()).toBe(true); + + await collectionRepository.save(otherUserCollection); + + // Execute the query for first user's card + const result = await queryRepository.getCollectionsContainingCardForUser( + card1.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result - should be empty since first user's card is not in any collections + expect(result).toHaveLength(0); + }); + + it('should only return collections owned by the requesting user', async () => { + const testUrl = 'https://example.com/multi-user-article'; + + // Create URL card for the user + const url = URL.create(testUrl).unwrap(); + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: url.value, + }, + }).unwrap(); + + const addToLibResult = card.addToLibrary(curatorId); + expect(addToLibResult.isOk()).toBe(true); + + await cardRepository.save(card); + + // Create user's own collection + const userCollection = Collection.create( + { + authorId: curatorId, + name: 'My Collection', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + const addToUserCollectionResult = userCollection.addCard( + card.cardId, + curatorId, + ); + expect(addToUserCollectionResult.isOk()).toBe(true); + + await collectionRepository.save(userCollection); + + // Create another user's collection (this should not appear in results) + const otherUserCollection = Collection.create( + { + authorId: otherCuratorId, + name: 'Other User Collection', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Note: We don't add the card to the other user's collection since they can't add + // another user's card to their collection in this domain model + + await collectionRepository.save(otherUserCollection); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result - should only see user's own collection + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('My Collection'); + expect(result[0]?.id).toBe(userCollection.collectionId.getStringValue()); + }); + }); + + describe('Note cards in collections', () => { + it('should return collections containing note cards', async () => { + // Create a note card + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'This is a test note', + }, + }).unwrap(); + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + expect(addToLibResult.isOk()).toBe(true); + + await cardRepository.save(card); + + // Create collection + const collection = Collection.create( + { + authorId: curatorId, + name: 'My Notes', + description: 'Collection of my personal notes', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Add card to collection + const addToCollectionResult = collection.addCard(card.cardId, curatorId); + expect(addToCollectionResult.isOk()).toBe(true); + + await collectionRepository.save(collection); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(collection.collectionId.getStringValue()); + expect(result[0]?.name).toBe('My Notes'); + expect(result[0]?.description).toBe('Collection of my personal notes'); + expect(result[0]?.uri).toBeUndefined(); // Not published + }); + + it('should handle collections with and without descriptions', async () => { + // Create a note card + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Test note for collections', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Create collection with description + const collectionWithDesc = Collection.create( + { + authorId: curatorId, + name: 'Collection With Description', + description: 'This collection has a description', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Create collection without description + const collectionWithoutDesc = Collection.create( + { + authorId: curatorId, + name: 'Collection Without Description', + // No description provided + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Add card to both collections + collectionWithDesc.addCard(card.cardId, curatorId); + collectionWithoutDesc.addCard(card.cardId, curatorId); + + await collectionRepository.save(collectionWithDesc); + await collectionRepository.save(collectionWithoutDesc); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(2); + + // Sort by name for consistent testing + result.sort((a, b) => a.name.localeCompare(b.name)); + + expect(result[0]?.name).toBe('Collection With Description'); + expect(result[0]?.description).toBe('This collection has a description'); + + expect(result[1]?.name).toBe('Collection Without Description'); + expect(result[1]?.description).toBeUndefined(); + }); + }); + + describe('Published and unpublished collections', () => { + it('should return URI for published collections and undefined for unpublished', async () => { + // Create a card + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Card for published/unpublished test', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Create published collection + const publishedCollection = Collection.create( + { + authorId: curatorId, + name: 'Published Collection', + description: 'This collection is published', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Create unpublished collection + const unpublishedCollection = Collection.create( + { + authorId: curatorId, + name: 'Unpublished Collection', + description: 'This collection is not published', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + // Mark published collection as published + const publishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/published123', + cid: 'bafyreipublishedcid', + }); + + publishedCollection.markAsPublished(publishedRecordId); + + // Add card to both collections + publishedCollection.addCard(card.cardId, curatorId); + unpublishedCollection.addCard(card.cardId, curatorId); + + await collectionRepository.save(publishedCollection); + await collectionRepository.save(unpublishedCollection); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(2); + + // Find collections by name + const publishedResult = result.find(c => c.name === 'Published Collection'); + const unpublishedResult = result.find(c => c.name === 'Unpublished Collection'); + + expect(publishedResult?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/published123'); + expect(unpublishedResult?.uri).toBeUndefined(); + }); + }); + + describe('Sorting and ordering', () => { + it('should return collections sorted by name in ascending order', async () => { + // Create a card + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Card for sorting test', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Create collections with names that will test alphabetical sorting + const collectionNames = ['Zebra Collection', 'Alpha Collection', 'Beta Collection']; + + for (const name of collectionNames) { + const collection = Collection.create( + { + authorId: curatorId, + name, + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + collection.addCard(card.cardId, curatorId); + await collectionRepository.save(collection); + } + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result is sorted by name + expect(result).toHaveLength(3); + expect(result[0]?.name).toBe('Alpha Collection'); + expect(result[1]?.name).toBe('Beta Collection'); + expect(result[2]?.name).toBe('Zebra Collection'); + }); + }); + + describe('Edge cases', () => { + it('should handle non-existent curator gracefully', async () => { + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Test card', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Execute the query with non-existent curator + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + 'did:plc:nonexistent', + ); + + // Verify the result + expect(result).toHaveLength(0); + }); + + it('should handle invalid card ID format gracefully', async () => { + // Execute the query with invalid card ID + const result = await queryRepository.getCollectionsContainingCardForUser( + 'invalid-card-id', + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(0); + }); + + it('should handle empty curator ID gracefully', async () => { + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Test card', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Execute the query with empty curator ID + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + '', + ); + + // Verify the result + expect(result).toHaveLength(0); + }); + + it('should handle collections with null published records', async () => { + // Create a card + const card = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Card for null published record test', + }, + }).unwrap(); + + await cardRepository.save(card); + + // Create collection without published record + const collection = Collection.create( + { + authorId: curatorId, + name: 'Collection Without Published Record', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + collection.addCard(card.cardId, curatorId); + await collectionRepository.save(collection); + + // Execute the query + const result = await queryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, + ); + + // Verify the result + expect(result).toHaveLength(1); + expect(result[0]?.uri).toBeUndefined(); + expect(result[0]?.name).toBe('Collection Without Published Record'); + }); + }); + + describe('Multiple card types', () => { + it('should work with different card types in the same collection', async () => { + // Create different types of cards + const urlCard = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.URL, + url: 'https://example.com/test', + }, + }).unwrap(); + + const noteCard = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.NOTE, + text: 'Test note', + }, + }).unwrap(); + + const highlightCard = CardFactory.create({ + curatorId: curatorId.value, + cardInput: { + type: CardTypeEnum.HIGHLIGHT, + text: 'Test highlight', + url: 'https://example.com/source', + }, + }).unwrap(); + + await cardRepository.save(urlCard); + await cardRepository.save(noteCard); + await cardRepository.save(highlightCard); + + // Create collection and add all cards + const collection = Collection.create( + { + authorId: curatorId, + name: 'Mixed Content Collection', + description: 'Collection with different card types', + accessType: CollectionAccessType.OPEN, + collaboratorIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + new UniqueEntityID(), + ).unwrap(); + + collection.addCard(urlCard.cardId, curatorId); + collection.addCard(noteCard.cardId, curatorId); + collection.addCard(highlightCard.cardId, curatorId); + + await collectionRepository.save(collection); + + // Test each card type + const urlResult = await queryRepository.getCollectionsContainingCardForUser( + urlCard.cardId.getStringValue(), + curatorId.value, + ); + + const noteResult = await queryRepository.getCollectionsContainingCardForUser( + noteCard.cardId.getStringValue(), + curatorId.value, + ); + + const highlightResult = await queryRepository.getCollectionsContainingCardForUser( + highlightCard.cardId.getStringValue(), + curatorId.value, + ); + + // Verify all return the same collection + expect(urlResult).toHaveLength(1); + expect(noteResult).toHaveLength(1); + expect(highlightResult).toHaveLength(1); + + expect(urlResult[0]?.name).toBe('Mixed Content Collection'); + expect(noteResult[0]?.name).toBe('Mixed Content Collection'); + expect(highlightResult[0]?.name).toBe('Mixed Content Collection'); + + expect(urlResult[0]?.id).toBe(collection.collectionId.getStringValue()); + expect(noteResult[0]?.id).toBe(collection.collectionId.getStringValue()); + expect(highlightResult[0]?.id).toBe(collection.collectionId.getStringValue()); + }); + }); +}); -- 2.51.2 From 6f36a9230a9780024398b3f74fd536fbba5e6384 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:28:30 -0700 Subject: [PATCH 66/76] feat: add critical performance indexes to collections, cards, and collection_cards Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../repositories/schema/card.sql.ts | 9 +++++++++ .../repositories/schema/collection.sql.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts index 3503d2a4..f2bc42b0 100644 --- a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts @@ -5,6 +5,7 @@ import { jsonb, uuid, integer, + index, type PgTableWithColumns, } from 'drizzle-orm/pg-core'; import { publishedRecords } from './publishedRecord.sql'; @@ -22,4 +23,12 @@ export const cards: PgTableWithColumns = pgTable('cards', { libraryCount: integer('library_count').notNull().default(0), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), +}, (table) => { + return { + // Critical for findUsersUrlCardByUrl queries + authorUrlIdx: index('cards_author_url_idx').on(table.authorId, table.url), + + // For general card queries by author + authorIdIdx: index('cards_author_id_idx').on(table.authorId), + }; }); diff --git a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts index 07c1b1ac..936e9a58 100644 --- a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts @@ -5,6 +5,7 @@ import { uuid, boolean, integer, + index, } from 'drizzle-orm/pg-core'; import { publishedRecords } from './publishedRecord.sql'; import { cards } from './card.sql'; @@ -21,6 +22,14 @@ export const collections = pgTable('collections', { publishedRecordId: uuid('published_record_id').references( () => publishedRecords.id, ), +}, (table) => { + return { + // Critical for all collection queries by user + authorIdIdx: index('collections_author_id_idx').on(table.authorId), + + // For paginated collection listings (most common sort) + authorUpdatedAtIdx: index('collections_author_updated_at_idx').on(table.authorId, table.updatedAt), + }; }); // Join table for collection collaborators @@ -46,4 +55,10 @@ export const collectionCards = pgTable('collection_cards', { publishedRecordId: uuid('published_record_id').references( () => publishedRecords.id, ), +}, (table) => { + return { + // Critical for getCollectionsContainingCardForUser queries + cardIdIdx: index('collection_cards_card_id_idx').on(table.cardId), + collectionIdIdx: index('collection_cards_collection_id_idx').on(table.collectionId), + }; }); -- 2.51.2 From cba61022692dfe351167cd43f90bfd2f3c27b031 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:32:24 -0700 Subject: [PATCH 67/76] feat: add indexes for cards, collections, and collection_cards tables in test schema Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../tests/test-utils/createTestSchema.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index ee33f160..1855b462 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -104,4 +104,28 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS published_records_uri_idx ON published_records(uri); `); + + // Cards table indexes + await db.execute(sql` + CREATE INDEX IF NOT EXISTS cards_author_url_idx ON cards(author_id, url); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS cards_author_id_idx ON cards(author_id); + `); + + // Collections table indexes + await db.execute(sql` + CREATE INDEX IF NOT EXISTS collections_author_id_idx ON collections(author_id); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS collections_author_updated_at_idx ON collections(author_id, updated_at); + `); + + // Collection cards table indexes + await db.execute(sql` + CREATE INDEX IF NOT EXISTS collection_cards_card_id_idx ON collection_cards(card_id); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS collection_cards_collection_id_idx ON collection_cards(collection_id); + `); } -- 2.51.2 From 1c81e13c987e659c0d9cf34e8923850a73c9422e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:33:42 -0700 Subject: [PATCH 68/76] update migration files based in added indexes --- .../migrations/0006_lovely_randall.sql | 6 + .../migrations/meta/0006_snapshot.json | 872 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + 3 files changed, 885 insertions(+) create mode 100644 src/shared/infrastructure/database/migrations/0006_lovely_randall.sql create mode 100644 src/shared/infrastructure/database/migrations/meta/0006_snapshot.json diff --git a/src/shared/infrastructure/database/migrations/0006_lovely_randall.sql b/src/shared/infrastructure/database/migrations/0006_lovely_randall.sql new file mode 100644 index 00000000..bc84a0a9 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0006_lovely_randall.sql @@ -0,0 +1,6 @@ +CREATE INDEX "cards_author_url_idx" ON "cards" USING btree ("author_id","url");--> statement-breakpoint +CREATE INDEX "cards_author_id_idx" ON "cards" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX "collection_cards_card_id_idx" ON "collection_cards" USING btree ("card_id");--> statement-breakpoint +CREATE INDEX "collection_cards_collection_id_idx" ON "collection_cards" USING btree ("collection_id");--> statement-breakpoint +CREATE INDEX "collections_author_id_idx" ON "collections" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX "collections_author_updated_at_idx" ON "collections" USING btree ("author_id","updated_at"); \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..71b71251 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json @@ -0,0 +1,872 @@ +{ + "id": "8d99a964-b5d3-497c-9b09-42a60ce3b09e", + "prevId": "2d3157fe-869f-46ed-bcdc-03fc757f16c1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cards_author_url_idx": { + "name": "cards_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_author_id_idx": { + "name": "cards_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": [ + "parent_card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_cards_card_id_idx": { + "name": "collection_cards_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_cards_collection_id_idx": { + "name": "collection_cards_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collections_author_id_idx": { + "name": "collections_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_author_updated_at_idx": { + "name": "collections_author_updated_at_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "tableTo": "published_records", + "columnsFrom": [ + "published_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": [ + "card_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_did" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index 44a7988e..b8580269 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1759957391430, "tag": "0005_truncate-tables", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1759962763890, + "tag": "0006_lovely_randall", + "breakpoints": true } ] } \ No newline at end of file -- 2.51.2 From a036828d8d8d20af06da859670483e08973a0352 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 15:36:38 -0700 Subject: [PATCH 69/76] test: update tests to use CardFactory with UrlMetadata and remove HIGHLIGHT card type Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- ...sContainingCardForUser.integration.test.ts | 148 +++++++++++------- 1 file changed, 90 insertions(+), 58 deletions(-) diff --git a/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts index c22675d7..859d0956 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsContainingCardForUser.integration.test.ts @@ -21,7 +21,7 @@ import { Collection, CollectionAccessType } from '../../domain/Collection'; import { CardFactory } from '../../domain/CardFactory'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; -import { URL } from '../../domain/value-objects/URL'; +import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; import { createTestSchema } from '../test-utils/createTestSchema'; describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser', () => { @@ -80,12 +80,20 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser const testUrl = 'https://example.com/test-article'; // Create a URL card - const url = URL.create(testUrl).unwrap(); + const urlMetadata = UrlMetadata.create({ + url: testUrl, + title: 'Test Article', + description: 'A test article for testing', + author: 'Test Author', + siteName: 'Example.com', + }).unwrap(); + const card = CardFactory.create({ curatorId: curatorId.value, cardInput: { type: CardTypeEnum.URL, - url: url.value, + url: testUrl, + metadata: urlMetadata, }, }).unwrap(); @@ -124,10 +132,16 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser ).unwrap(); // Add card to both collections - const addToCollection1Result = collection1.addCard(card.cardId, curatorId); + const addToCollection1Result = collection1.addCard( + card.cardId, + curatorId, + ); expect(addToCollection1Result.isOk()).toBe(true); - const addToCollection2Result = collection2.addCard(card.cardId, curatorId); + const addToCollection2Result = collection2.addCard( + card.cardId, + curatorId, + ); expect(addToCollection2Result.isOk()).toBe(true); // Mark collections as published @@ -162,12 +176,16 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser // Verify collection details expect(result[0]?.id).toBe(collection2.collectionId.getStringValue()); // Reading List comes first alphabetically - expect(result[0]?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection2'); + expect(result[0]?.uri).toBe( + 'at://did:plc:testcurator/network.cosmik.collection/collection2', + ); expect(result[0]?.name).toBe('Reading List'); expect(result[0]?.description).toBe('My personal reading list'); expect(result[1]?.id).toBe(collection1.collectionId.getStringValue()); // Tech Articles comes second - expect(result[1]?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection1'); + expect(result[1]?.uri).toBe( + 'at://did:plc:testcurator/network.cosmik.collection/collection1', + ); expect(result[1]?.name).toBe('Tech Articles'); expect(result[1]?.description).toBe('Collection of technology articles'); }); @@ -176,12 +194,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser const testUrl = 'https://example.com/standalone-article'; // Create a URL card - const url = URL.create(testUrl).unwrap(); + const urlMetadata = UrlMetadata.create({ + url: testUrl, + title: 'Standalone Article', + description: 'A standalone article for testing', + }).unwrap(); + const card = CardFactory.create({ curatorId: curatorId.value, cardInput: { type: CardTypeEnum.URL, - url: url.value, + url: testUrl, + metadata: urlMetadata, }, }).unwrap(); @@ -218,12 +242,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser const testUrl = 'https://example.com/shared-article'; // Create URL card for first user - const url = URL.create(testUrl).unwrap(); + const urlMetadata1 = UrlMetadata.create({ + url: testUrl, + title: 'Shared Article', + description: 'An article shared between users', + }).unwrap(); + const card1 = CardFactory.create({ curatorId: curatorId.value, cardInput: { type: CardTypeEnum.URL, - url: url.value, + url: testUrl, + metadata: urlMetadata1, }, }).unwrap(); @@ -233,11 +263,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser await cardRepository.save(card1); // Create URL card for second user (different card, same URL) + const urlMetadata2 = UrlMetadata.create({ + url: testUrl, + title: 'Shared Article', + description: 'An article shared between users', + }).unwrap(); + const card2 = CardFactory.create({ curatorId: otherCuratorId.value, cardInput: { type: CardTypeEnum.URL, - url: url.value, + url: testUrl, + metadata: urlMetadata2, }, }).unwrap(); @@ -281,12 +318,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser const testUrl = 'https://example.com/multi-user-article'; // Create URL card for the user - const url = URL.create(testUrl).unwrap(); + const urlMetadata = UrlMetadata.create({ + url: testUrl, + title: 'Multi User Article', + description: 'An article for multi-user testing', + }).unwrap(); + const card = CardFactory.create({ curatorId: curatorId.value, cardInput: { type: CardTypeEnum.URL, - url: url.value, + url: testUrl, + metadata: urlMetadata, }, }).unwrap(); @@ -531,10 +574,16 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser expect(result).toHaveLength(2); // Find collections by name - const publishedResult = result.find(c => c.name === 'Published Collection'); - const unpublishedResult = result.find(c => c.name === 'Unpublished Collection'); + const publishedResult = result.find( + (c) => c.name === 'Published Collection', + ); + const unpublishedResult = result.find( + (c) => c.name === 'Unpublished Collection', + ); - expect(publishedResult?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/published123'); + expect(publishedResult?.uri).toBe( + 'at://did:plc:testcurator/network.cosmik.collection/published123', + ); expect(unpublishedResult?.uri).toBeUndefined(); }); }); @@ -553,8 +602,12 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser await cardRepository.save(card); // Create collections with names that will test alphabetical sorting - const collectionNames = ['Zebra Collection', 'Alpha Collection', 'Beta Collection']; - + const collectionNames = [ + 'Zebra Collection', + 'Alpha Collection', + 'Beta Collection', + ]; + for (const name of collectionNames) { const collection = Collection.create( { @@ -608,17 +661,6 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser expect(result).toHaveLength(0); }); - it('should handle invalid card ID format gracefully', async () => { - // Execute the query with invalid card ID - const result = await queryRepository.getCollectionsContainingCardForUser( - 'invalid-card-id', - curatorId.value, - ); - - // Verify the result - expect(result).toHaveLength(0); - }); - it('should handle empty curator ID gracefully', async () => { const card = CardFactory.create({ curatorId: curatorId.value, @@ -684,11 +726,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser describe('Multiple card types', () => { it('should work with different card types in the same collection', async () => { // Create different types of cards + const urlMetadata = UrlMetadata.create({ + url: 'https://example.com/test', + title: 'Test URL', + description: 'A test URL for mixed content', + }).unwrap(); + const urlCard = CardFactory.create({ curatorId: curatorId.value, cardInput: { type: CardTypeEnum.URL, url: 'https://example.com/test', + metadata: urlMetadata, }, }).unwrap(); @@ -700,18 +749,8 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser }, }).unwrap(); - const highlightCard = CardFactory.create({ - curatorId: curatorId.value, - cardInput: { - type: CardTypeEnum.HIGHLIGHT, - text: 'Test highlight', - url: 'https://example.com/source', - }, - }).unwrap(); - await cardRepository.save(urlCard); await cardRepository.save(noteCard); - await cardRepository.save(highlightCard); // Create collection and add all cards const collection = Collection.create( @@ -729,38 +768,31 @@ describe('DrizzleCollectionQueryRepository - getCollectionsContainingCardForUser collection.addCard(urlCard.cardId, curatorId); collection.addCard(noteCard.cardId, curatorId); - collection.addCard(highlightCard.cardId, curatorId); await collectionRepository.save(collection); // Test each card type - const urlResult = await queryRepository.getCollectionsContainingCardForUser( - urlCard.cardId.getStringValue(), - curatorId.value, - ); - - const noteResult = await queryRepository.getCollectionsContainingCardForUser( - noteCard.cardId.getStringValue(), - curatorId.value, - ); - - const highlightResult = await queryRepository.getCollectionsContainingCardForUser( - highlightCard.cardId.getStringValue(), - curatorId.value, - ); + const urlResult = + await queryRepository.getCollectionsContainingCardForUser( + urlCard.cardId.getStringValue(), + curatorId.value, + ); + + const noteResult = + await queryRepository.getCollectionsContainingCardForUser( + noteCard.cardId.getStringValue(), + curatorId.value, + ); // Verify all return the same collection expect(urlResult).toHaveLength(1); expect(noteResult).toHaveLength(1); - expect(highlightResult).toHaveLength(1); expect(urlResult[0]?.name).toBe('Mixed Content Collection'); expect(noteResult[0]?.name).toBe('Mixed Content Collection'); - expect(highlightResult[0]?.name).toBe('Mixed Content Collection'); expect(urlResult[0]?.id).toBe(collection.collectionId.getStringValue()); expect(noteResult[0]?.id).toBe(collection.collectionId.getStringValue()); - expect(highlightResult[0]?.id).toBe(collection.collectionId.getStringValue()); }); }); }); -- 2.51.2 From 47d704e6c30a11d438fa460a877b7de604a76417 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 16:20:19 -0700 Subject: [PATCH 70/76] feat: add `getUrlStatusForMyLibrary` method to ApiClient Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/webapp/api-client/ApiClient.ts | 9 +++++++++ src/webapp/api-client/clients/QueryClient.ts | 12 ++++++++++++ src/webapp/api-client/types/requests.ts | 4 ++++ src/webapp/api-client/types/responses.ts | 10 ++++++++++ 4 files changed, 35 insertions(+) diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index b530427e..a8b63d4c 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -57,6 +57,8 @@ import type { GetUrlCardsResponse, GetProfileResponse, GetProfileParams, + GetUrlStatusForMyLibraryParams, + GetUrlStatusForMyLibraryResponse, } from './types'; // Main API Client class using composition @@ -152,6 +154,13 @@ export class ApiClient { return this.queryClient.getUserCollections(params); } + async getUrlStatusForMyLibrary( + params: GetUrlStatusForMyLibraryParams, + ): Promise { + this.requireAuthentication('getUrlStatusForMyLibrary'); + return this.queryClient.getUrlStatusForMyLibrary(params); + } + // Card operations - delegate to CardClient (all require authentication) async addUrlToLibrary( request: AddUrlToLibraryRequest, diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index 5a21f258..26074da1 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -7,6 +7,7 @@ import { GetProfileResponse, GetCollectionPageResponse, GetCollectionsResponse, + GetUrlStatusForMyLibraryResponse, GetMyUrlCardsParams, GetUrlCardsParams, GetCollectionPageParams, @@ -14,6 +15,7 @@ import { GetCollectionsParams, GetCollectionPageByAtUriParams, GetProfileParams, + GetUrlStatusForMyLibraryParams, } from '../types'; export class QueryClient extends BaseClient { @@ -157,4 +159,14 @@ export class QueryClient extends BaseClient { return this.request('GET', endpoint); } + + async getUrlStatusForMyLibrary( + params: GetUrlStatusForMyLibraryParams, + ): Promise { + const searchParams = new URLSearchParams({ url: params.url }); + return this.request( + 'GET', + `/api/cards/library/status?${searchParams}`, + ); + } } diff --git a/src/webapp/api-client/types/requests.ts b/src/webapp/api-client/types/requests.ts index 17821ec9..2c6b9789 100644 --- a/src/webapp/api-client/types/requests.ts +++ b/src/webapp/api-client/types/requests.ts @@ -127,3 +127,7 @@ export interface GenerateExtensionTokensRequest { export interface GetProfileParams { identifier: string; // Can be DID or handle } + +export interface GetUrlStatusForMyLibraryParams { + url: string; +} diff --git a/src/webapp/api-client/types/responses.ts b/src/webapp/api-client/types/responses.ts index 1e76e84e..0b088d07 100644 --- a/src/webapp/api-client/types/responses.ts +++ b/src/webapp/api-client/types/responses.ts @@ -297,3 +297,13 @@ export interface GetGlobalFeedResponse { nextCursor?: string; }; } + +export interface GetUrlStatusForMyLibraryResponse { + cardId?: string; + collections?: { + id: string; + uri?: string; + name: string; + description?: string; + }[]; +} -- 2.51.2 From a247112794c0f39a6f5f02cdfaf22febc4175b70 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 16:40:24 -0700 Subject: [PATCH 71/76] refactor: use collection string from EnvironmentConfigService instead of hardcoded ids Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../useCases/queries/GetCollectionPageByAtUriUseCase.ts | 4 ++-- src/shared/infrastructure/http/factories/ServiceFactory.ts | 2 ++ src/shared/infrastructure/http/factories/UseCaseFactory.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts b/src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts index 7d1e59d0..a10d4be2 100644 --- a/src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts @@ -13,7 +13,6 @@ import { CardSortField, SortOrder, } from 'src/modules/cards/domain/ICardQueryRepository'; -import { REPOSITORY_COLLECTION_IDS } from 'src/modules/atproto/infrastructure/services/RepositoryCollectionIds'; export interface GetCollectionPageByAtUriQuery { handle: string; @@ -33,6 +32,7 @@ export class GetCollectionPageByAtUriUseCase private identityResolutionService: IIdentityResolutionService, private atUriResolutionService: IAtUriResolutionService, private getCollectionPageUseCase: GetCollectionPageUseCase, + private collectionString: string, ) {} async execute( @@ -60,7 +60,7 @@ export class GetCollectionPageByAtUriUseCase // Construct the AT URI using the resolved DID const atUriResult = ATUri.fromParts( didResult.value, - REPOSITORY_COLLECTION_IDS.COLLECTIONS, + this.collectionString, query.recordKey, ); if (atUriResult.isErr()) { diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index 3abd4d14..a48e211d 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -73,6 +73,7 @@ export interface WebAppServices extends SharedServices { cardCollectionService: CardCollectionService; authMiddleware: AuthMiddleware; eventPublisher: IEventPublisher; + collectionString: string; } // Worker specific services (includes subscribers) @@ -175,6 +176,7 @@ export class ServiceFactory { cardCollectionService, authMiddleware, eventPublisher, + collectionString: collections.collection, }; } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 5934a80d..79616d98 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -166,6 +166,7 @@ export class UseCaseFactory { services.identityResolutionService, repositories.atUriResolutionService, getCollectionPageUseCase, + services.collectionString, ), getCollectionsUseCase: new GetCollectionsUseCase( repositories.collectionQueryRepository, -- 2.51.2 From 11c4b05e1313c86f259790285e53fe3390ed6b3e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 16:55:16 -0700 Subject: [PATCH 72/76] refactor: pass EnvironmentConfigService as shared service and update collection string injection Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/shared/infrastructure/http/factories/ServiceFactory.ts | 4 ++-- src/shared/infrastructure/http/factories/UseCaseFactory.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index a48e211d..29de0c7c 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -61,6 +61,7 @@ export interface SharedServices { feedService: FeedService; nodeOauthClient: NodeOAuthClient; identityResolutionService: IIdentityResolutionService; + configService: EnvironmentConfigService; } // Web app specific services (includes publishers, auth middleware) @@ -73,7 +74,6 @@ export interface WebAppServices extends SharedServices { cardCollectionService: CardCollectionService; authMiddleware: AuthMiddleware; eventPublisher: IEventPublisher; - collectionString: string; } // Worker specific services (includes subscribers) @@ -176,7 +176,6 @@ export class ServiceFactory { cardCollectionService, authMiddleware, eventPublisher, - collectionString: collections.collection, }; } @@ -297,6 +296,7 @@ export class ServiceFactory { feedService, nodeOauthClient, identityResolutionService, + configService, }; } } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 79616d98..006b1546 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -166,7 +166,7 @@ export class UseCaseFactory { services.identityResolutionService, repositories.atUriResolutionService, getCollectionPageUseCase, - services.collectionString, + services.configService.getAtProtoCollections().collection, ), getCollectionsUseCase: new GetCollectionsUseCase( repositories.collectionQueryRepository, -- 2.51.2 From 9352dbf079b768114e08e507b06888d22d92d11d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 8 Oct 2025 17:06:34 -0700 Subject: [PATCH 73/76] formatting and linting --- .../atproto/infrastructure/lexicon/index.ts | 84 ++++++++-------- .../infrastructure/lexicon/lexicons.ts | 18 ++-- .../types/com/atproto/repo/strongRef.ts | 24 ++--- .../lexicon/types/network/cosmik/card.ts | 86 +++++++++-------- .../types/network/cosmik/collection.ts | 38 ++++---- .../types/network/cosmik/collectionLink.ts | 40 ++++---- .../lexicon/types/network/cosmik/defs.ts | 28 +++--- .../atproto/infrastructure/lexicon/util.ts | 32 +++---- src/modules/cards/domain/Card.ts | 36 +++++-- .../domain/ICollectionQueryRepository.ts | 2 +- .../infrastructure/http/routes/cardRoutes.ts | 6 +- .../repositories/schema/card.sql.ts | 48 +++++----- .../repositories/schema/collection.sql.ts | 95 +++++++++++-------- .../RemoveCardFromLibraryUseCase.test.ts | 22 +++-- src/modules/cards/tests/domain/Card.test.ts | 8 +- .../config/EnvironmentConfigService.ts | 15 ++- .../migrations/meta/0004_snapshot.json | 87 +++++------------ .../migrations/meta/0005_snapshot.json | 87 +++++------------ .../migrations/meta/0006_snapshot.json | 87 +++++------------ .../database/migrations/meta/_journal.json | 2 +- 20 files changed, 394 insertions(+), 451 deletions(-) diff --git a/src/modules/atproto/infrastructure/lexicon/index.ts b/src/modules/atproto/infrastructure/lexicon/index.ts index 9d24d6c3..21d14790 100644 --- a/src/modules/atproto/infrastructure/lexicon/index.ts +++ b/src/modules/atproto/infrastructure/lexicon/index.ts @@ -7,93 +7,93 @@ import { type Options as XrpcOptions, type AuthVerifier, type StreamAuthVerifier, -} from '@atproto/xrpc-server' -import { schemas } from './lexicons.js' +} from '@atproto/xrpc-server'; +import { schemas } from './lexicons.js'; export function createServer(options?: XrpcOptions): Server { - return new Server(options) + return new Server(options); } export class Server { - xrpc: XrpcServer - network: NetworkNS - com: ComNS + xrpc: XrpcServer; + network: NetworkNS; + com: ComNS; constructor(options?: XrpcOptions) { - this.xrpc = createXrpcServer(schemas, options) - this.network = new NetworkNS(this) - this.com = new ComNS(this) + this.xrpc = createXrpcServer(schemas, options); + this.network = new NetworkNS(this); + this.com = new ComNS(this); } } export class NetworkNS { - _server: Server - cosmik: NetworkCosmikNS + _server: Server; + cosmik: NetworkCosmikNS; constructor(server: Server) { - this._server = server - this.cosmik = new NetworkCosmikNS(server) + this._server = server; + this.cosmik = new NetworkCosmikNS(server); } } export class NetworkCosmikNS { - _server: Server + _server: Server; constructor(server: Server) { - this._server = server + this._server = server; } } export class ComNS { - _server: Server - atproto: ComAtprotoNS + _server: Server; + atproto: ComAtprotoNS; constructor(server: Server) { - this._server = server - this.atproto = new ComAtprotoNS(server) + this._server = server; + this.atproto = new ComAtprotoNS(server); } } export class ComAtprotoNS { - _server: Server - repo: ComAtprotoRepoNS + _server: Server; + repo: ComAtprotoRepoNS; constructor(server: Server) { - this._server = server - this.repo = new ComAtprotoRepoNS(server) + this._server = server; + this.repo = new ComAtprotoRepoNS(server); } } export class ComAtprotoRepoNS { - _server: Server + _server: Server; constructor(server: Server) { - this._server = server + this._server = server; } } type SharedRateLimitOpts = { - name: string - calcKey?: (ctx: T) => string | null - calcPoints?: (ctx: T) => number -} + name: string; + calcKey?: (ctx: T) => string | null; + calcPoints?: (ctx: T) => number; +}; type RouteRateLimitOpts = { - durationMs: number - points: number - calcKey?: (ctx: T) => string | null - calcPoints?: (ctx: T) => number -} -type HandlerOpts = { blobLimit?: number } -type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts + durationMs: number; + points: number; + calcKey?: (ctx: T) => string | null; + calcPoints?: (ctx: T) => number; +}; +type HandlerOpts = { blobLimit?: number }; +type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts; type ConfigOf = | Handler | { - auth?: Auth - opts?: HandlerOpts - rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] - handler: Handler - } + auth?: Auth; + opts?: HandlerOpts; + rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[]; + handler: Handler; + }; type ExtractAuth = Extract< Awaited>, { credentials: unknown } -> +>; diff --git a/src/modules/atproto/infrastructure/lexicon/lexicons.ts b/src/modules/atproto/infrastructure/lexicon/lexicons.ts index ae9f5a73..06f5c05b 100644 --- a/src/modules/atproto/infrastructure/lexicon/lexicons.ts +++ b/src/modules/atproto/infrastructure/lexicon/lexicons.ts @@ -6,8 +6,8 @@ import { Lexicons, ValidationError, type ValidationResult, -} from '@atproto/lexicon' -import { type $Typed, is$typed, maybe$typed } from './util.js' +} from '@atproto/lexicon'; +import { type $Typed, is$typed, maybe$typed } from './util.js'; export const schemaDict = { NetworkCosmikCard: { @@ -286,22 +286,22 @@ export const schemaDict = { }, }, }, -} as const satisfies Record -export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] -export const lexicons: Lexicons = new Lexicons(schemas) +} as const satisfies Record; +export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]; +export const lexicons: Lexicons = new Lexicons(schemas); export function validate( v: unknown, id: string, hash: string, requiredType: true, -): ValidationResult +): ValidationResult; export function validate( v: unknown, id: string, hash: string, requiredType?: false, -): ValidationResult +): ValidationResult; export function validate( v: unknown, id: string, @@ -315,7 +315,7 @@ export function validate( error: new ValidationError( `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, ), - } + }; } export const ids = { @@ -324,4 +324,4 @@ export const ids = { NetworkCosmikCollectionLink: 'network.cosmik.collectionLink', NetworkCosmikDefs: 'network.cosmik.defs', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', -} as const +} as const; diff --git a/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts b/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts index eac0bc16..e0149a5b 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts @@ -1,31 +1,31 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../../lexicons' +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../../lexicons'; import { type $Typed, is$typed as _is$typed, type OmitKey, -} from '../../../../util' +} from '../../../../util'; const is$typed = _is$typed, - validate = _validate -const id = 'com.atproto.repo.strongRef' + validate = _validate; +const id = 'com.atproto.repo.strongRef'; export interface Main { - $type?: 'com.atproto.repo.strongRef' - cid: string - uri: string + $type?: 'com.atproto.repo.strongRef'; + cid: string; + uri: string; } -const hashMain = 'main' +const hashMain = 'main'; export function isMain(v: V) { - return is$typed(v, id, hashMain) + return is$typed(v, id, hashMain); } export function validateMain(v: V) { - return validate
(v, id, hashMain) + return validate
(v, id, hashMain); } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts index bd2dcf98..1fc00ae6 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/card.ts @@ -1,102 +1,106 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js' +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../lexicons'; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../util'; +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; const is$typed = _is$typed, - validate = _validate -const id = 'network.cosmik.card' + validate = _validate; +const id = 'network.cosmik.card'; export interface Record { - $type: 'network.cosmik.card' + $type: 'network.cosmik.card'; /** The type of card */ - type: 'URL' | 'NOTE' | (string & {}) - content: $Typed | $Typed | { $type: string } + type: 'URL' | 'NOTE' | (string & {}); + content: $Typed | $Typed | { $type: string }; /** Optional URL associated with the card. Required for URL cards, optional for NOTE cards. */ - url?: string - parentCard?: ComAtprotoRepoStrongRef.Main + url?: string; + parentCard?: ComAtprotoRepoStrongRef.Main; /** Timestamp when this card was created (usually set by PDS). */ - createdAt?: string - originalCard?: ComAtprotoRepoStrongRef.Main - [k: string]: unknown + createdAt?: string; + originalCard?: ComAtprotoRepoStrongRef.Main; + [k: string]: unknown; } -const hashRecord = 'main' +const hashRecord = 'main'; export function isRecord(v: V) { - return is$typed(v, id, hashRecord) + return is$typed(v, id, hashRecord); } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true) + return validate(v, id, hashRecord, true); } /** Content structure for a URL card. */ export interface UrlContent { - $type?: 'network.cosmik.card#urlContent' + $type?: 'network.cosmik.card#urlContent'; /** The URL being saved */ - url: string - metadata?: UrlMetadata + url: string; + metadata?: UrlMetadata; } -const hashUrlContent = 'urlContent' +const hashUrlContent = 'urlContent'; export function isUrlContent(v: V) { - return is$typed(v, id, hashUrlContent) + return is$typed(v, id, hashUrlContent); } export function validateUrlContent(v: V) { - return validate(v, id, hashUrlContent) + return validate(v, id, hashUrlContent); } /** Content structure for a note card. */ export interface NoteContent { - $type?: 'network.cosmik.card#noteContent' + $type?: 'network.cosmik.card#noteContent'; /** The note text content */ - text: string + text: string; } -const hashNoteContent = 'noteContent' +const hashNoteContent = 'noteContent'; export function isNoteContent(v: V) { - return is$typed(v, id, hashNoteContent) + return is$typed(v, id, hashNoteContent); } export function validateNoteContent(v: V) { - return validate(v, id, hashNoteContent) + return validate(v, id, hashNoteContent); } /** Metadata about a URL. */ export interface UrlMetadata { - $type?: 'network.cosmik.card#urlMetadata' + $type?: 'network.cosmik.card#urlMetadata'; /** Title of the page */ - title?: string + title?: string; /** Description of the page */ - description?: string + description?: string; /** Author of the content */ - author?: string + author?: string; /** When the content was published */ - publishedDate?: string + publishedDate?: string; /** Name of the site */ - siteName?: string + siteName?: string; /** URL of a representative image */ - imageUrl?: string + imageUrl?: string; /** Type of content (e.g., 'video', 'article') */ - type?: string + type?: string; /** When the metadata was retrieved */ - retrievedAt?: string + retrievedAt?: string; } -const hashUrlMetadata = 'urlMetadata' +const hashUrlMetadata = 'urlMetadata'; export function isUrlMetadata(v: V) { - return is$typed(v, id, hashUrlMetadata) + return is$typed(v, id, hashUrlMetadata); } export function validateUrlMetadata(v: V) { - return validate(v, id, hashUrlMetadata) + return validate(v, id, hashUrlMetadata); } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts index ee696f15..06751324 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collection.ts @@ -1,38 +1,42 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../lexicons'; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../util'; const is$typed = _is$typed, - validate = _validate -const id = 'network.cosmik.collection' + validate = _validate; +const id = 'network.cosmik.collection'; export interface Record { - $type: 'network.cosmik.collection' + $type: 'network.cosmik.collection'; /** Name of the collection */ - name: string + name: string; /** Description of the collection */ - description?: string + description?: string; /** Access control for the collection */ - accessType: 'OPEN' | 'CLOSED' | (string & {}) + accessType: 'OPEN' | 'CLOSED' | (string & {}); /** List of collaborator DIDs who can add cards to closed collections */ - collaborators?: string[] + collaborators?: string[]; /** Timestamp when this collection was created (usually set by PDS). */ - createdAt?: string + createdAt?: string; /** Timestamp when this collection was last updated. */ - updatedAt?: string - [k: string]: unknown + updatedAt?: string; + [k: string]: unknown; } -const hashRecord = 'main' +const hashRecord = 'main'; export function isRecord(v: V) { - return is$typed(v, id, hashRecord) + return is$typed(v, id, hashRecord); } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true) + return validate(v, id, hashRecord, true); } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts index f37b9628..2b7be1c6 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLink.ts @@ -1,36 +1,40 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' -import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js' +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../lexicons'; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../util'; +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; const is$typed = _is$typed, - validate = _validate -const id = 'network.cosmik.collectionLink' + validate = _validate; +const id = 'network.cosmik.collectionLink'; export interface Record { - $type: 'network.cosmik.collectionLink' - collection: ComAtprotoRepoStrongRef.Main - card: ComAtprotoRepoStrongRef.Main - originalCard?: ComAtprotoRepoStrongRef.Main + $type: 'network.cosmik.collectionLink'; + collection: ComAtprotoRepoStrongRef.Main; + card: ComAtprotoRepoStrongRef.Main; + originalCard?: ComAtprotoRepoStrongRef.Main; /** DID of the user who added the card to the collection */ - addedBy: string + addedBy: string; /** Timestamp when the card was added to the collection. */ - addedAt: string + addedAt: string; /** Timestamp when this link record was created (usually set by PDS). */ - createdAt?: string - [k: string]: unknown + createdAt?: string; + [k: string]: unknown; } -const hashRecord = 'main' +const hashRecord = 'main'; export function isRecord(v: V) { - return is$typed(v, id, hashRecord) + return is$typed(v, id, hashRecord); } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true) + return validate(v, id, hashRecord, true); } diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts index 02cc5945..83f3e7c2 100644 --- a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/defs.ts @@ -1,30 +1,34 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../lexicons'; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../util'; const is$typed = _is$typed, - validate = _validate -const id = 'network.cosmik.defs' + validate = _validate; +const id = 'network.cosmik.defs'; /** Represents an identifier with a type and value. */ export interface Identifier { - $type?: 'network.cosmik.defs#identifier' + $type?: 'network.cosmik.defs#identifier'; /** The type of identifier (e.g., 'doi', 'at-uri', 'isbn'). */ - type: string + type: string; /** The identifier value. */ - value: string + value: string; } -const hashIdentifier = 'identifier' +const hashIdentifier = 'identifier'; export function isIdentifier(v: V) { - return is$typed(v, id, hashIdentifier) + return is$typed(v, id, hashIdentifier); } export function validateIdentifier(v: V) { - return validate(v, id, hashIdentifier) + return validate(v, id, hashIdentifier); } diff --git a/src/modules/atproto/infrastructure/lexicon/util.ts b/src/modules/atproto/infrastructure/lexicon/util.ts index 816ca10c..f7e33604 100644 --- a/src/modules/atproto/infrastructure/lexicon/util.ts +++ b/src/modules/atproto/infrastructure/lexicon/util.ts @@ -2,21 +2,21 @@ * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult } from '@atproto/lexicon' +import { type ValidationResult } from '@atproto/lexicon'; export type OmitKey = { - [K2 in keyof T as K2 extends K ? never : K2]: T[K2] -} + [K2 in keyof T as K2 extends K ? never : K2]: T[K2]; +}; -export type $Typed = V & { $type: T } -export type Un$Typed = OmitKey +export type $Typed = V & { $type: T }; +export type Un$Typed = OmitKey; export type $Type = Hash extends 'main' ? Id - : `${Id}#${Hash}` + : `${Id}#${Hash}`; function isObject(v: V): v is V & object { - return v != null && typeof v === 'object' + return v != null && typeof v === 'object'; } function is$type( @@ -31,7 +31,7 @@ function is$type( $type.length === id.length + 1 + hash.length && $type.charCodeAt(id.length) === 35 /* '#' */ && $type.startsWith(id) && - $type.endsWith(hash) + $type.endsWith(hash); } export type $TypedObject< @@ -39,21 +39,21 @@ export type $TypedObject< Id extends string, Hash extends string, > = V extends { - $type: $Type + $type: $Type; } ? V : V extends { $type?: string } ? V extends { $type?: infer T extends $Type } ? V & { $type: T } : never - : V & { $type: $Type } + : V & { $type: $Type }; export function is$typed( v: V, id: Id, hash: Hash, ): v is $TypedObject { - return isObject(v) && '$type' in v && is$type(v.$type, id, hash) + return isObject(v) && '$type' in v && is$type(v.$type, id, hash); } export function maybe$typed( @@ -64,12 +64,12 @@ export function maybe$typed( return ( isObject(v) && ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) - ) + ); } -export type Validator = (v: unknown) => ValidationResult +export type Validator = (v: unknown) => ValidationResult; export type ValidatorParam = - V extends Validator ? R : never + V extends Validator ? R : never; /** * Utility function that allows to convert a "validate*" utility function into a @@ -77,6 +77,6 @@ export type ValidatorParam = */ export function asPredicate(validate: V) { return function (v: T): v is T & ValidatorParam { - return validate(v).success - } + return validate(v).success; + }; } diff --git a/src/modules/cards/domain/Card.ts b/src/modules/cards/domain/Card.ts index 48cdb654..c0dd9e62 100644 --- a/src/modules/cards/domain/Card.ts +++ b/src/modules/cards/domain/Card.ts @@ -14,8 +14,10 @@ export const CARD_ERROR_MESSAGES = { URL_CARD_CANNOT_HAVE_PARENT: 'URL cards cannot have parent cards', URL_CARD_MUST_HAVE_URL: 'URL cards must have a url property', URL_CARD_SINGLE_LIBRARY_ONLY: 'URL cards can only be in one library', - URL_CARD_CREATOR_LIBRARY_ONLY: 'URL cards can only be in the library of the creator', - LIBRARY_COUNT_MISMATCH: 'Library count does not match library memberships length', + URL_CARD_CREATOR_LIBRARY_ONLY: + 'URL cards can only be in the library of the creator', + LIBRARY_COUNT_MISMATCH: + 'Library count does not match library memberships length', CANNOT_CHANGE_CONTENT_TYPE: 'Cannot change card content to different type', ALREADY_IN_LIBRARY: "Card is already in user's library", NOT_IN_LIBRARY: "Card is not in user's library", @@ -123,7 +125,9 @@ export class Card extends AggregateRoot { ): Result { // Validate content type matches card type if (props.type.value !== props.content.type) { - return err(new CardValidationError(CARD_ERROR_MESSAGES.CARD_TYPE_CONTENT_MISMATCH)); + return err( + new CardValidationError(CARD_ERROR_MESSAGES.CARD_TYPE_CONTENT_MISMATCH), + ); } // Validate parent/source card relationships @@ -157,12 +161,18 @@ export class Card extends AggregateRoot { ): Result { // URL cards should not have parent cards if (props.type.value === CardTypeEnum.URL && props.parentCardId) { - return err(new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_CANNOT_HAVE_PARENT)); + return err( + new CardValidationError( + CARD_ERROR_MESSAGES.URL_CARD_CANNOT_HAVE_PARENT, + ), + ); } // URL cards must have a URL property if (props.type.value === CardTypeEnum.URL && !props.url) { - return err(new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_MUST_HAVE_URL)); + return err( + new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_MUST_HAVE_URL), + ); } // URL cards can only be in one library @@ -172,7 +182,9 @@ export class Card extends AggregateRoot { libraryMemberships.length > 1 ) { return err( - new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY), + new CardValidationError( + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, + ), ); } @@ -186,7 +198,9 @@ export class Card extends AggregateRoot { ); if (hasNonCreatorMembership) { return err( - new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY), + new CardValidationError( + CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY, + ), ); } } @@ -227,13 +241,17 @@ export class Card extends AggregateRoot { link.curatorId.equals(userId), ) ) { - return err(new CardValidationError(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY)); + return err( + new CardValidationError(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY), + ); } // URL cards can only be in one library if (this.isUrlCard && this.props.libraryMemberships.length > 0) { return err( - new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY), + new CardValidationError( + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, + ), ); } diff --git a/src/modules/cards/domain/ICollectionQueryRepository.ts b/src/modules/cards/domain/ICollectionQueryRepository.ts index fe36974c..c8602fee 100644 --- a/src/modules/cards/domain/ICollectionQueryRepository.ts +++ b/src/modules/cards/domain/ICollectionQueryRepository.ts @@ -49,7 +49,7 @@ export interface ICollectionQueryRepository { curatorId: string, options: CollectionQueryOptions, ): Promise>; - + getCollectionsContainingCardForUser( cardId: string, curatorId: string, diff --git a/src/modules/cards/infrastructure/http/routes/cardRoutes.ts b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts index 8c7634ce..648a291e 100644 --- a/src/modules/cards/infrastructure/http/routes/cardRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts @@ -42,8 +42,10 @@ export function createCardRoutes( ); // GET /api/cards/library/status - Get URL status for my library - router.get('/library/status', authMiddleware.ensureAuthenticated(), (req, res) => - getUrlStatusForMyLibraryController.execute(req, res), + router.get( + '/library/status', + authMiddleware.ensureAuthenticated(), + (req, res) => getUrlStatusForMyLibraryController.execute(req, res), ); // GET /api/cards/user/:identifier - Get user's URL cards by identifier diff --git a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts index f2bc42b0..8881c24a 100644 --- a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts @@ -10,25 +10,29 @@ import { } from 'drizzle-orm/pg-core'; import { publishedRecords } from './publishedRecord.sql'; -export const cards: PgTableWithColumns = pgTable('cards', { - id: uuid('id').primaryKey(), - authorId: text('author_id').notNull(), - type: text('type').notNull(), // URL, NOTE, HIGHLIGHT - contentData: jsonb('content_data').notNull(), - url: text('url'), // Optional URL field for all card types - parentCardId: uuid('parent_card_id').references(() => cards.id), - publishedRecordId: uuid('published_record_id').references( - () => publishedRecords.id, - ), - libraryCount: integer('library_count').notNull().default(0), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}, (table) => { - return { - // Critical for findUsersUrlCardByUrl queries - authorUrlIdx: index('cards_author_url_idx').on(table.authorId, table.url), - - // For general card queries by author - authorIdIdx: index('cards_author_id_idx').on(table.authorId), - }; -}); +export const cards: PgTableWithColumns = pgTable( + 'cards', + { + id: uuid('id').primaryKey(), + authorId: text('author_id').notNull(), + type: text('type').notNull(), // URL, NOTE, HIGHLIGHT + contentData: jsonb('content_data').notNull(), + url: text('url'), // Optional URL field for all card types + parentCardId: uuid('parent_card_id').references(() => cards.id), + publishedRecordId: uuid('published_record_id').references( + () => publishedRecords.id, + ), + libraryCount: integer('library_count').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => { + return { + // Critical for findUsersUrlCardByUrl queries + authorUrlIdx: index('cards_author_url_idx').on(table.authorId, table.url), + + // For general card queries by author + authorIdIdx: index('cards_author_id_idx').on(table.authorId), + }; + }, +); diff --git a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts index 936e9a58..48c092fc 100644 --- a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts @@ -10,27 +10,34 @@ import { import { publishedRecords } from './publishedRecord.sql'; import { cards } from './card.sql'; -export const collections = pgTable('collections', { - id: uuid('id').primaryKey(), - authorId: text('author_id').notNull(), - name: text('name').notNull(), - description: text('description'), - accessType: text('access_type').notNull(), // OPEN, CLOSED - cardCount: integer('card_count').notNull().default(0), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - publishedRecordId: uuid('published_record_id').references( - () => publishedRecords.id, - ), -}, (table) => { - return { - // Critical for all collection queries by user - authorIdIdx: index('collections_author_id_idx').on(table.authorId), - - // For paginated collection listings (most common sort) - authorUpdatedAtIdx: index('collections_author_updated_at_idx').on(table.authorId, table.updatedAt), - }; -}); +export const collections = pgTable( + 'collections', + { + id: uuid('id').primaryKey(), + authorId: text('author_id').notNull(), + name: text('name').notNull(), + description: text('description'), + accessType: text('access_type').notNull(), // OPEN, CLOSED + cardCount: integer('card_count').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + publishedRecordId: uuid('published_record_id').references( + () => publishedRecords.id, + ), + }, + (table) => { + return { + // Critical for all collection queries by user + authorIdIdx: index('collections_author_id_idx').on(table.authorId), + + // For paginated collection listings (most common sort) + authorUpdatedAtIdx: index('collections_author_updated_at_idx').on( + table.authorId, + table.updatedAt, + ), + }; + }, +); // Join table for collection collaborators export const collectionCollaborators = pgTable('collection_collaborators', { @@ -42,23 +49,29 @@ export const collectionCollaborators = pgTable('collection_collaborators', { }); // Join table for cards in collections -export const collectionCards = pgTable('collection_cards', { - id: uuid('id').primaryKey(), - collectionId: uuid('collection_id') - .notNull() - .references(() => collections.id, { onDelete: 'cascade' }), - cardId: uuid('card_id') - .notNull() - .references(() => cards.id, { onDelete: 'cascade' }), - addedBy: text('added_by').notNull(), - addedAt: timestamp('added_at').notNull().defaultNow(), - publishedRecordId: uuid('published_record_id').references( - () => publishedRecords.id, - ), -}, (table) => { - return { - // Critical for getCollectionsContainingCardForUser queries - cardIdIdx: index('collection_cards_card_id_idx').on(table.cardId), - collectionIdIdx: index('collection_cards_collection_id_idx').on(table.collectionId), - }; -}); +export const collectionCards = pgTable( + 'collection_cards', + { + id: uuid('id').primaryKey(), + collectionId: uuid('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + cardId: uuid('card_id') + .notNull() + .references(() => cards.id, { onDelete: 'cascade' }), + addedBy: text('added_by').notNull(), + addedAt: timestamp('added_at').notNull().defaultNow(), + publishedRecordId: uuid('published_record_id').references( + () => publishedRecords.id, + ), + }, + (table) => { + return { + // Critical for getCollectionsContainingCardForUser queries + cardIdIdx: index('collection_cards_card_id_idx').on(table.cardId), + collectionIdIdx: index('collection_cards_collection_id_idx').on( + table.collectionId, + ), + }; + }, +); diff --git a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts index 90b2e2fa..be068b58 100644 --- a/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts @@ -51,8 +51,14 @@ describe('RemoveCardFromLibraryUseCase', () => { cardPublisher.clear(); }); - const createCard = async (type: CardTypeEnum = CardTypeEnum.URL, creatorId: CuratorId = curatorId) => { - const card = new CardBuilder().withType(type).withCuratorId(creatorId.value).build(); + const createCard = async ( + type: CardTypeEnum = CardTypeEnum.URL, + creatorId: CuratorId = curatorId, + ) => { + const card = new CardBuilder() + .withType(type) + .withCuratorId(creatorId.value) + .build(); if (card instanceof Error) { throw new Error(`Failed to create card: ${card.message}`); @@ -65,9 +71,9 @@ describe('RemoveCardFromLibraryUseCase', () => { const addCardToLibrary = async (card: any, curatorId: CuratorId) => { // For URL cards, can only add to creator's library if (card.isUrlCard && !card.curatorId.equals(curatorId)) { - throw new Error('URL cards can only be added to creator\'s library'); + throw new Error("URL cards can only be added to creator's library"); } - + const addResult = await cardLibraryService.addCardToLibrary( card, curatorId, @@ -169,7 +175,7 @@ describe('RemoveCardFromLibraryUseCase', () => { it('should handle different card types', async () => { // Create URL card with curatorId as creator const urlCard = await createCard(CardTypeEnum.URL, curatorId); - // Create note card with curatorId as creator + // Create note card with curatorId as creator const noteCard = await createCard(CardTypeEnum.NOTE, curatorId); await addCardToLibrary(urlCard, curatorId); @@ -520,10 +526,10 @@ describe('RemoveCardFromLibraryUseCase', () => { it('should not delete card when curator is not the owner', async () => { // Create card with different owner const card = await createCard(CardTypeEnum.NOTE, otherCuratorId); - + // Add to other curator's library first await addCardToLibrary(card, otherCuratorId); - + // Add to current curator's library (note cards can be in multiple libraries) await addCardToLibrary(card, curatorId); @@ -548,7 +554,7 @@ describe('RemoveCardFromLibraryUseCase', () => { it('should not delete card when it still has other library memberships', async () => { // Create note card (can be in multiple libraries) const card = await createCard(CardTypeEnum.NOTE, curatorId); - + // Add to both curator's libraries await addCardToLibrary(card, curatorId); await addCardToLibrary(card, otherCuratorId); diff --git a/src/modules/cards/tests/domain/Card.test.ts b/src/modules/cards/tests/domain/Card.test.ts index 3b8028a6..151cc699 100644 --- a/src/modules/cards/tests/domain/Card.test.ts +++ b/src/modules/cards/tests/domain/Card.test.ts @@ -151,7 +151,9 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY); + expect(result.error.message).toBe( + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, + ); }); it('should allow NOTE cards to have multiple library memberships', () => { @@ -256,7 +258,9 @@ describe('Card', () => { throw new Error('Expected creation to fail but it succeeded'); } expect(result.isErr()).toBe(true); - expect(result.error.message).toBe(CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY); + expect(result.error.message).toBe( + CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, + ); expect(card.libraryMembershipCount).toBe(1); expect(card.isInLibrary(curatorId)).toBe(true); expect(card.isInLibrary(otherUserId)).toBe(false); diff --git a/src/shared/infrastructure/config/EnvironmentConfigService.ts b/src/shared/infrastructure/config/EnvironmentConfigService.ts index aa097dac..1b447f5d 100644 --- a/src/shared/infrastructure/config/EnvironmentConfigService.ts +++ b/src/shared/infrastructure/config/EnvironmentConfigService.ts @@ -69,9 +69,18 @@ export class EnvironmentConfigService { process.env.ATPROTO_SERVICE_ENDPOINT || 'https://bsky.social', baseUrl: process.env.BASE_URL || 'http://127.0.0.1:3000', collections: { - card: environment === 'prod' ? 'network.cosmik.card' : 'network.cosmik.dev.card', - collection: environment === 'prod' ? 'network.cosmik.collection' : 'network.cosmik.dev.collection', - collectionLink: environment === 'prod' ? 'network.cosmik.collectionLink' : 'network.cosmik.dev.collectionLink', + card: + environment === 'prod' + ? 'network.cosmik.card' + : 'network.cosmik.dev.card', + collection: + environment === 'prod' + ? 'network.cosmik.collection' + : 'network.cosmik.dev.collection', + collectionLink: + environment === 'prod' + ? 'network.cosmik.collectionLink' + : 'network.cosmik.dev.collectionLink', }, }, server: { diff --git a/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json index bfb9e0ce..db051288 100644 --- a/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json +++ b/src/shared/infrastructure/database/migrations/meta/0004_snapshot.json @@ -123,12 +123,8 @@ "name": "cards_parent_card_id_cards_id_fk", "tableFrom": "cards", "tableTo": "cards", - "columnsFrom": [ - "parent_card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_card_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -136,12 +132,8 @@ "name": "cards_published_record_id_published_records_id_fk", "tableFrom": "cards", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -200,12 +192,8 @@ "name": "collection_cards_collection_id_collections_id_fk", "tableFrom": "collection_cards", "tableTo": "collections", - "columnsFrom": [ - "collection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -213,12 +201,8 @@ "name": "collection_cards_card_id_cards_id_fk", "tableFrom": "collection_cards", "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["card_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -226,12 +210,8 @@ "name": "collection_cards_published_record_id_published_records_id_fk", "tableFrom": "collection_cards", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -271,12 +251,8 @@ "name": "collection_collaborators_collection_id_collections_id_fk", "tableFrom": "collection_collaborators", "tableTo": "collections", - "columnsFrom": [ - "collection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -355,12 +331,8 @@ "name": "collections_published_record_id_published_records_id_fk", "tableFrom": "collections", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -438,12 +410,8 @@ "name": "library_memberships_card_id_cards_id_fk", "tableFrom": "library_memberships", "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["card_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -451,12 +419,8 @@ "name": "library_memberships_published_record_id_published_records_id_fk", "tableFrom": "library_memberships", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -464,10 +428,7 @@ "compositePrimaryKeys": { "library_memberships_card_id_user_id_pk": { "name": "library_memberships_card_id_user_id_pk", - "columns": [ - "card_id", - "user_id" - ] + "columns": ["card_id", "user_id"] } }, "uniqueConstraints": {}, @@ -699,12 +660,8 @@ "name": "auth_refresh_tokens_user_did_users_id_fk", "tableFrom": "auth_refresh_tokens", "tableTo": "users", - "columnsFrom": [ - "user_did" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_did"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -764,4 +721,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json index ebbfb9f6..6fadd60c 100644 --- a/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json +++ b/src/shared/infrastructure/database/migrations/meta/0005_snapshot.json @@ -122,26 +122,18 @@ "cards_parent_card_id_cards_id_fk": { "name": "cards_parent_card_id_cards_id_fk", "tableFrom": "cards", - "columnsFrom": [ - "parent_card_id" - ], + "columnsFrom": ["parent_card_id"], "tableTo": "cards", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" }, "cards_published_record_id_published_records_id_fk": { "name": "cards_published_record_id_published_records_id_fk", "tableFrom": "cards", - "columnsFrom": [ - "published_record_id" - ], + "columnsFrom": ["published_record_id"], "tableTo": "published_records", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" } @@ -199,39 +191,27 @@ "collection_cards_collection_id_collections_id_fk": { "name": "collection_cards_collection_id_collections_id_fk", "tableFrom": "collection_cards", - "columnsFrom": [ - "collection_id" - ], + "columnsFrom": ["collection_id"], "tableTo": "collections", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" }, "collection_cards_card_id_cards_id_fk": { "name": "collection_cards_card_id_cards_id_fk", "tableFrom": "collection_cards", - "columnsFrom": [ - "card_id" - ], + "columnsFrom": ["card_id"], "tableTo": "cards", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" }, "collection_cards_published_record_id_published_records_id_fk": { "name": "collection_cards_published_record_id_published_records_id_fk", "tableFrom": "collection_cards", - "columnsFrom": [ - "published_record_id" - ], + "columnsFrom": ["published_record_id"], "tableTo": "published_records", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" } @@ -270,13 +250,9 @@ "collection_collaborators_collection_id_collections_id_fk": { "name": "collection_collaborators_collection_id_collections_id_fk", "tableFrom": "collection_collaborators", - "columnsFrom": [ - "collection_id" - ], + "columnsFrom": ["collection_id"], "tableTo": "collections", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -354,13 +330,9 @@ "collections_published_record_id_published_records_id_fk": { "name": "collections_published_record_id_published_records_id_fk", "tableFrom": "collections", - "columnsFrom": [ - "published_record_id" - ], + "columnsFrom": ["published_record_id"], "tableTo": "published_records", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" } @@ -437,26 +409,18 @@ "library_memberships_card_id_cards_id_fk": { "name": "library_memberships_card_id_cards_id_fk", "tableFrom": "library_memberships", - "columnsFrom": [ - "card_id" - ], + "columnsFrom": ["card_id"], "tableTo": "cards", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" }, "library_memberships_published_record_id_published_records_id_fk": { "name": "library_memberships_published_record_id_published_records_id_fk", "tableFrom": "library_memberships", - "columnsFrom": [ - "published_record_id" - ], + "columnsFrom": ["published_record_id"], "tableTo": "published_records", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" } @@ -464,10 +428,7 @@ "compositePrimaryKeys": { "library_memberships_card_id_user_id_pk": { "name": "library_memberships_card_id_user_id_pk", - "columns": [ - "card_id", - "user_id" - ] + "columns": ["card_id", "user_id"] } }, "uniqueConstraints": {}, @@ -698,13 +659,9 @@ "auth_refresh_tokens_user_did_users_id_fk": { "name": "auth_refresh_tokens_user_did_users_id_fk", "tableFrom": "auth_refresh_tokens", - "columnsFrom": [ - "user_did" - ], + "columnsFrom": ["user_did"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "no action" } @@ -764,4 +721,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json index 71b71251..c9bc18fb 100644 --- a/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json +++ b/src/shared/infrastructure/database/migrations/meta/0006_snapshot.json @@ -160,12 +160,8 @@ "name": "cards_parent_card_id_cards_id_fk", "tableFrom": "cards", "tableTo": "cards", - "columnsFrom": [ - "parent_card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_card_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -173,12 +169,8 @@ "name": "cards_published_record_id_published_records_id_fk", "tableFrom": "cards", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -268,12 +260,8 @@ "name": "collection_cards_collection_id_collections_id_fk", "tableFrom": "collection_cards", "tableTo": "collections", - "columnsFrom": [ - "collection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -281,12 +269,8 @@ "name": "collection_cards_card_id_cards_id_fk", "tableFrom": "collection_cards", "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["card_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -294,12 +278,8 @@ "name": "collection_cards_published_record_id_published_records_id_fk", "tableFrom": "collection_cards", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -339,12 +319,8 @@ "name": "collection_collaborators_collection_id_collections_id_fk", "tableFrom": "collection_collaborators", "tableTo": "collections", - "columnsFrom": [ - "collection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -460,12 +436,8 @@ "name": "collections_published_record_id_published_records_id_fk", "tableFrom": "collections", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -543,12 +515,8 @@ "name": "library_memberships_card_id_cards_id_fk", "tableFrom": "library_memberships", "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["card_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -556,12 +524,8 @@ "name": "library_memberships_published_record_id_published_records_id_fk", "tableFrom": "library_memberships", "tableTo": "published_records", - "columnsFrom": [ - "published_record_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -569,10 +533,7 @@ "compositePrimaryKeys": { "library_memberships_card_id_user_id_pk": { "name": "library_memberships_card_id_user_id_pk", - "columns": [ - "card_id", - "user_id" - ] + "columns": ["card_id", "user_id"] } }, "uniqueConstraints": {}, @@ -804,12 +765,8 @@ "name": "auth_refresh_tokens_user_did_users_id_fk", "tableFrom": "auth_refresh_tokens", "tableTo": "users", - "columnsFrom": [ - "user_did" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_did"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -869,4 +826,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index b8580269..aa94d9de 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -52,4 +52,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} -- 2.51.2 From f208364f7e26e4a860999fb444b1ea04a7d625a5 Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Thu, 9 Oct 2025 11:05:44 -0700 Subject: [PATCH 74/76] refactor: useUrlMetadata --- src/webapp/components/UrlCardForm.tsx | 10 ++++------ src/webapp/hooks/useUrlMetadata.ts | 28 ++++++++++++++++----------- src/webapp/next-env.d.ts | 1 + 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/webapp/components/UrlCardForm.tsx b/src/webapp/components/UrlCardForm.tsx index 7e6c1ca8..3cfce207 100644 --- a/src/webapp/components/UrlCardForm.tsx +++ b/src/webapp/components/UrlCardForm.tsx @@ -47,7 +47,7 @@ export function UrlCardForm({ // URL metadata hook const { metadata, - existingCard, + existingCardCollections, loading: metadataLoading, error: metadataError, } = useUrlMetadata({ @@ -58,11 +58,9 @@ export function UrlCardForm({ // Get existing collections for this card (filtered by current user) const existingCollections = useMemo(() => { - if (!existingCard || !userId) return []; - return existingCard.collections.filter( - (collection) => collection.authorId === userId, - ); - }, [existingCard, userId]); + if (!existingCardCollections || !userId) return []; + return existingCardCollections.map((c) => ({ ...c, authorId: userId })); + }, [existingCardCollections, userId]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/webapp/hooks/useUrlMetadata.ts b/src/webapp/hooks/useUrlMetadata.ts index d467f0cf..477fe870 100644 --- a/src/webapp/hooks/useUrlMetadata.ts +++ b/src/webapp/hooks/useUrlMetadata.ts @@ -1,7 +1,10 @@ import { useState, useEffect, useCallback } from 'react'; import { ApiClient } from '@/api-client/ApiClient'; import type { UrlMetadata } from '@/components/UrlMetadataDisplay'; -import type { UrlCardView } from '@/api-client/types'; +import type { + GetUrlStatusForMyLibraryResponse, + UrlCardView, +} from '@/api-client/types'; interface UseUrlMetadataProps { apiClient: ApiClient; @@ -15,7 +18,9 @@ export function useUrlMetadata({ autoFetch = true, }: UseUrlMetadataProps) { const [metadata, setMetadata] = useState(null); - const [existingCard, setExistingCard] = useState(null); + const [existingCardCollections, setExistingCardCollections] = useState< + GetUrlStatusForMyLibraryResponse['collections'] | null + >(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -33,19 +38,20 @@ export function useUrlMetadata({ setLoading(true); setError(null); - setExistingCard(null); + setExistingCardCollections(null); try { const response = await apiClient.getUrlMetadata(targetUrl); setMetadata(response.metadata); - // If there's an existing card, fetch its details including collections - if (response.existingCardId) { + const existingCard = await apiClient.getUrlStatusForMyLibrary({ + url: targetUrl, + }); + + // If there's an existing card, fetch its collections + if (existingCard.cardId) { try { - const cardResponse = await apiClient.getUrlCardView( - response.existingCardId, - ); - setExistingCard(cardResponse); + setExistingCardCollections(existingCard.collections); } catch (cardErr: any) { console.error('Failed to fetch existing card details:', cardErr); // Don't set error here as the metadata fetch was successful @@ -55,7 +61,7 @@ export function useUrlMetadata({ console.error('Failed to fetch URL metadata:', err); setError('Failed to load page information'); setMetadata(null); - setExistingCard(null); + setExistingCardCollections(null); } finally { setLoading(false); } @@ -78,7 +84,7 @@ export function useUrlMetadata({ return { metadata, - existingCard, + existingCardCollections, loading, error, fetchMetadata, diff --git a/src/webapp/next-env.d.ts b/src/webapp/next-env.d.ts index 1b3be084..830fb594 100644 --- a/src/webapp/next-env.d.ts +++ b/src/webapp/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. -- 2.51.2 From 18b5bb11774bb04cb12e8d038198a163dca6d39e Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Thu, 9 Oct 2025 12:50:06 -0700 Subject: [PATCH 75/76] fix: outdated uri on fake collection builder and fake card publisher --- src/modules/cards/tests/utils/FakeCardPublisher.ts | 2 +- src/modules/cards/tests/utils/FakeCollectionPublisher.ts | 2 +- src/modules/cards/tests/utils/builders/CollectionBuilder.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/cards/tests/utils/FakeCardPublisher.ts b/src/modules/cards/tests/utils/FakeCardPublisher.ts index 97343f0d..fcb6be68 100644 --- a/src/modules/cards/tests/utils/FakeCardPublisher.ts +++ b/src/modules/cards/tests/utils/FakeCardPublisher.ts @@ -29,7 +29,7 @@ export class FakeCardPublisher implements ICardPublisher { const cardId = card.cardId.getStringValue(); // Simulate generating an AT URI based on curator DID and collection/rkey - const fakeUri = `at://${curatorId.value}/network.cosmik.card/${cardId}`; + const fakeUri = `at://${curatorId.value}/network.cosmik.dev.card/${cardId}`; const fakeCid = `fake-cid-${cardId}`; const publishedRecordId = PublishedRecordId.create({ uri: fakeUri, diff --git a/src/modules/cards/tests/utils/FakeCollectionPublisher.ts b/src/modules/cards/tests/utils/FakeCollectionPublisher.ts index 30efaf48..bf61d65e 100644 --- a/src/modules/cards/tests/utils/FakeCollectionPublisher.ts +++ b/src/modules/cards/tests/utils/FakeCollectionPublisher.ts @@ -33,7 +33,7 @@ export class FakeCollectionPublisher implements ICollectionPublisher { const fakeDid = process.env.BSKY_DID || 'did:plc:rlknsba2qldjkicxsmni3vyn'; // Simulate publishing the collection record itself - const fakeCollectionUri = `at://${fakeDid}/network.cosmik.collection/${collectionId}`; + const fakeCollectionUri = `at://${fakeDid}/network.cosmik.dev.collection/${collectionId}`; const fakeCollectionCid = `fake-collection-cid-${collectionId}`; const collectionRecord = PublishedRecordId.create({ diff --git a/src/modules/cards/tests/utils/builders/CollectionBuilder.ts b/src/modules/cards/tests/utils/builders/CollectionBuilder.ts index 159464cd..96ba26a2 100644 --- a/src/modules/cards/tests/utils/builders/CollectionBuilder.ts +++ b/src/modules/cards/tests/utils/builders/CollectionBuilder.ts @@ -57,7 +57,7 @@ export class CollectionBuilder { withPublished(published: boolean): CollectionBuilder { if (published) { // Create a fake published record ID when marking as published - const fakeUri = `at://fake-did/network.cosmik.collection/${this._id?.toString() || 'fake-id'}`; + const fakeUri = `at://fake-did/network.cosmik.dev.collection/${this._id?.toString() || 'fake-id'}`; const fakeCid = `fake-collection-cid-${this._id?.toString() || 'fake-id'}`; this._publishedRecordId = PublishedRecordId.create({ uri: fakeUri, -- 2.51.2 From 81acdc8bd5cf5996ac88f2286939111e239c4b3b Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Thu, 9 Oct 2025 13:53:37 -0700 Subject: [PATCH 76/76] refactor: update methods for adding url and checking existing collections a card is in Use getUrlStatusForMyLibrary and addUrlToLibrary --- .../addCardToModal/AddCardToModal.tsx | 28 +- .../addCardToModal/CardToBeAddedPreview.tsx | 33 ++- .../lib/mutations/useAddCardToLibrary.tsx | 14 +- .../features/cards/lib/queries/useGetCard.tsx | 2 +- .../lib/queries/useGetCardFromMyLibrary.tsx | 21 ++ .../AddToCollectionModal.tsx | 279 ------------------ .../CardToBeAddedPreview.tsx | 93 ------ 7 files changed, 62 insertions(+), 408 deletions(-) create mode 100644 src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx delete mode 100644 src/webapp/features/collections/components/addToCollectionModal/AddToCollectionModal.tsx delete mode 100644 src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index 7018c06a..da8f2846 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -15,19 +15,17 @@ import { Alert, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; import { Fragment, useState } from 'react'; import { IoSearch } from 'react-icons/io5'; import { BiPlus } from 'react-icons/bi'; -import useMyCollections from '../../../collections/lib/queries/useMyCollections'; -import useCard from '@/features/cards/lib/queries/useGetCard'; -import { notifications } from '@mantine/notifications'; import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; import CollectionSelectorItemList from '../../../collections/components/collectionSelectorItemList/CollectionSelectorItemList'; import CreateCollectionDrawer from '../../../collections/components/createCollectionDrawer/CreateCollectionDrawer'; import CardToBeAddedPreview from './CardToBeAddedPreview'; import useAddCardToLibrary from '../../lib/mutations/useAddCardToLibrary'; -import useGetLibrariesForCard from '../../lib/queries/useGetLibrariesForcard'; -import { useAuth } from '@/hooks/useAuth'; +import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; +import useMyCollections from '../../../collections/lib/queries/useMyCollections'; interface Props { isOpen: boolean; @@ -37,18 +35,15 @@ interface Props { } export default function AddCardToModal(props: Props) { - const { user, isLoading: isLoadingUser } = useAuth(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebouncedValue(search, 200); const searchedCollections = useCollectionSearch({ query: debouncedSearch }); - const libraries = useGetLibrariesForCard({ id: props.cardId }); - const isInUserLibrary = libraries.data.users.some((u) => u.id === user?.id); - const addCardToLibrary = useAddCardToLibrary(); - const card = useCard({ id: props.cardId }); + + const cardStaus = useGetCardFromMyLibrary({ url: props.cardContent.url }); const { data, error } = useMyCollections(); const [selectedCollections, setSelectedCollections] = useState< SelectableCollectionItem[] @@ -73,13 +68,15 @@ export default function AddCardToModal(props: Props) { data?.pages.flatMap((page) => page.collections ?? []) ?? []; const collectionsWithCard = allCollections.filter((c) => - card.data?.collections.some((col) => col.id === c.id), + cardStaus.data.collections?.some((col) => col.id === c.id), ); const collectionsWithoutCard = allCollections.filter( (c) => !collectionsWithCard.some((col) => col.id === c.id), ); + const isInUserLibrary = collectionsWithCard.length > 0; + const hasCollections = allCollections.length > 0; const hasSelectedCollections = selectedCollections.length > 0; @@ -88,7 +85,7 @@ export default function AddCardToModal(props: Props) { addCardToLibrary.mutate( { - cardId: props.cardId, + url: props.cardContent.url, collectionIds: selectedCollections.map((c) => c.id), }, { @@ -278,11 +275,8 @@ export default function AddCardToModal(props: Props) { onClick={handleAddCard} // disabled when: // user already has the card in a collection (and therefore in library) - // and when it's already in library and no new collection is selected yet - disabled={ - isLoadingUser || - (isInUserLibrary && selectedCollections.length === 0) - } + // and no new collection is selected yet + disabled={isInUserLibrary && selectedCollections.length === 0} loading={addCardToLibrary.isPending} > Add diff --git a/src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx b/src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx index 501e074f..3b07ad34 100644 --- a/src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx +++ b/src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx @@ -11,17 +11,21 @@ import { Anchor, } from '@mantine/core'; import Link from 'next/link'; -import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; +import { + GetUrlStatusForMyLibraryResponse, + UrlCardView, +} from '@/api-client/types'; import { BiCollection } from 'react-icons/bi'; import { LuLibrary } from 'react-icons/lu'; import { getDomain } from '@/lib/utils/link'; import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; import { getRecordKey } from '@/lib/utils/atproto'; +import { Fragment } from 'react'; interface Props { cardId: string; cardContent: UrlCardView['cardContent']; - collectionsWithCard: GetCollectionsResponse['collections']; + collectionsWithCard: GetUrlStatusForMyLibraryResponse['collections']; isInLibrary: boolean; } @@ -78,7 +82,7 @@ export default function CardToBeAddedPreview(props: Props) { In Library )} - {props.collectionsWithCard.length > 0 && ( + {props.collectionsWithCard && props.collectionsWithCard.length > 0 && ( - - {searchedCollections.isPending && ( - - - Searching collections... - - - - )} - - {searchedCollections.data && - (searchedCollections.data.collections.length === 0 ? ( - - ) : ( - - ))} - - ) : hasCollections ? ( - - ) : ( - - - No collections - - - - )} - - - - - - - - {hasSelectedCollections ? ( - - ) : ( - - )} - - - - - - - - {hasSelectedCollections && ( - - )} - - - - - - setIsDrawerOpen(false)} - initialName={search} - onCreate={(newCollection) => { - setSelectedCollections([...selectedCollections, newCollection]); - }} - /> - - ); -} diff --git a/src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx b/src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx deleted file mode 100644 index 8f23933e..00000000 --- a/src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - AspectRatio, - Group, - Stack, - Image, - Text, - Card, - Menu, - Button, - ScrollArea, - Anchor, -} from '@mantine/core'; -import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; -import Link from 'next/link'; -import { getDomain } from '@/lib/utils/link'; -import { BiSolidChevronDownCircle } from 'react-icons/bi'; - -interface Props { - cardContent: UrlCardView['cardContent']; - collectionsWithCard: GetCollectionsResponse['collections']; -} - -export default function CardToBeAddedPreview(props: Props) { - const domain = getDomain(props.cardContent.url); - - return ( - - - - - {props.cardContent.thumbnailUrl && ( - - {`${props.cardContent.url} - - )} - - - {domain} - - {props.cardContent.title && ( - - {props.cardContent.title} - - )} - - - {props.collectionsWithCard.length > 0 && ( - - - - - - - {props.collectionsWithCard.map((c) => ( - - {c.name} - - ))} - - - - )} - - - - ); -}