diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index ec3f867d..d4967ea4 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -11,9 +11,6 @@ import { } from '../../../../cards/domain/ICardQueryRepository'; import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; -import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; -import { DID } from '../../../../atproto/domain/DID'; -import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; import { GetGlobalFeedResponse, FeedItem } from '@semble/types'; export interface GetGlobalFeedQuery { @@ -44,7 +41,6 @@ export class GetGlobalFeedUseCase private profileService: IProfileService, private cardQueryRepository: ICardQueryRepository, private collectionRepository: ICollectionRepository, - private identityResolutionService: IIdentityResolutionService, ) {} async execute( -- 2.54.0 From 5c7a90b2c6671d57f73345ca2e5c3ba0f8adce20 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:42:55 -0700 Subject: [PATCH 02/11] These changes look comprehensive and well-structured. The implementation follows the recommended approach of adding a new `getUrlCardBasic` method that: 1. Fetches only the URL card data 2. Includes a note only if it's by the same author 3. Avoids fetching collections or libraries 4. Provides a lightweight, efficient query The test coverage looks thorough, testing various scenarios like: - Non-existent cards - Non-URL cards - Cards with and without metadata - Cards with notes by the same/different authors A few key improvements: - Simplified the use case by using the new query method - Updated type definitions to return a full `UrlCard` - Maintained the existing pattern of using `CardMapper` for transformations Would you like me to elaborate on any part of the implementation or discuss any potential optimizations? Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.ts | 157 +++++++++----- .../cards/domain/ICardQueryRepository.ts | 5 + .../DrizzleCardQueryRepository.ts | 7 + .../query-services/UrlCardQueryService.ts | 109 ++++++++++ ...sitory.getUrlCardBasic.integration.test.ts | 205 ++++++++++++++++++ src/types/src/api/responses.ts | 2 +- 6 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index c230ae73..571cf10a 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -4,13 +4,14 @@ 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 { ICardQueryRepository } from '../../../domain/ICardQueryRepository'; import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; import { ICollectionRepository } from '../../../domain/ICollectionRepository'; import { IProfileService } from '../../../domain/services/IProfileService'; import { CuratorId } from '../../../domain/value-objects/CuratorId'; import { URL } from '../../../domain/value-objects/URL'; import { CollectionId } from '../../../domain/value-objects/CollectionId'; -import { CollectionDTO } from '@semble/types'; +import { CollectionDTO, UrlCard } from '@semble/types'; export interface GetUrlStatusForMyLibraryQuery { url: string; @@ -18,7 +19,7 @@ export interface GetUrlStatusForMyLibraryQuery { } export interface GetUrlStatusForMyLibraryResult { - cardId?: string; + card?: UrlCard; collections?: CollectionDTO[]; } @@ -37,6 +38,7 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< > { constructor( private cardRepository: ICardRepository, + private cardQueryRepository: ICardQueryRepository, private collectionQueryRepository: ICollectionQueryRepository, private collectionRepo: ICollectionRepository, private profileService: IProfileService, @@ -85,65 +87,104 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< const result: GetUrlStatusForMyLibraryResult = {}; if (card) { - result.cardId = card.cardId.getStringValue(); - - // Get collections containing this card for the user - try { - const collections = - await this.collectionQueryRepository.getCollectionsContainingCardForUser( - card.cardId.getStringValue(), - curatorId.value, - ); + // Get enriched card data with note + const cardView = await this.cardQueryRepository.getUrlCardBasic( + card.cardId.getStringValue(), + curatorId.value, + ); - // Enrich collections with full data - result.collections = await Promise.all( - collections.map(async (collection): Promise => { - // Fetch full collection to get dates and cardCount - const collectionIdResult = CollectionId.createFromString( - collection.id, - ); - if (collectionIdResult.isErr()) { - throw new Error(`Invalid collection ID: ${collection.id}`); - } - const collectionResult = await this.collectionRepo.findById( - collectionIdResult.value, - ); - if (collectionResult.isErr() || !collectionResult.value) { - throw new Error(`Collection not found: ${collection.id}`); - } - const fullCollection = collectionResult.value; - - // Fetch author profile - const authorProfileResult = await this.profileService.getProfile( - fullCollection.authorId.value, + if (cardView) { + // Get card author profile + const authorProfileResult = await this.profileService.getProfile( + cardView.authorId, + curatorId.value, + ); + + if (authorProfileResult.isErr()) { + return err(AppError.UnexpectedError.create(authorProfileResult.error)); + } + + const authorProfile = authorProfileResult.value; + + // Transform to UrlCard format + result.card = { + id: cardView.id, + type: 'URL', + url: cardView.url, + cardContent: cardView.cardContent, + libraryCount: cardView.libraryCount, + urlLibraryCount: cardView.urlLibraryCount, + urlInLibrary: cardView.urlInLibrary, + createdAt: cardView.createdAt.toISOString(), + updatedAt: cardView.updatedAt.toISOString(), + author: { + id: authorProfile.id, + name: authorProfile.name, + handle: authorProfile.handle, + avatarUrl: authorProfile.avatarUrl, + description: authorProfile.bio, + }, + note: cardView.note, // This includes the note if it exists + }; + + // Get collections containing this card for the user + try { + const collections = + await this.collectionQueryRepository.getCollectionsContainingCardForUser( + card.cardId.getStringValue(), + curatorId.value, ); - if (authorProfileResult.isErr()) { - throw new Error( - `Failed to fetch author profile: ${authorProfileResult.error.message}`, + + // Enrich collections with full data + result.collections = await Promise.all( + collections.map(async (collection): Promise => { + // Fetch full collection to get dates and cardCount + const collectionIdResult = CollectionId.createFromString( + collection.id, ); - } - const authorProfile = authorProfileResult.value; - - return { - id: collection.id, - uri: collection.uri, - name: collection.name, - description: collection.description, - author: { - id: authorProfile.id, - name: authorProfile.name, - handle: authorProfile.handle, - avatarUrl: authorProfile.avatarUrl, - description: authorProfile.bio, - }, - cardCount: fullCollection.cardCount, - createdAt: fullCollection.createdAt.toISOString(), - updatedAt: fullCollection.updatedAt.toISOString(), - }; - }), - ); - } catch (error) { - return err(AppError.UnexpectedError.create(error)); + if (collectionIdResult.isErr()) { + throw new Error(`Invalid collection ID: ${collection.id}`); + } + const collectionResult = await this.collectionRepo.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr() || !collectionResult.value) { + throw new Error(`Collection not found: ${collection.id}`); + } + const fullCollection = collectionResult.value; + + // Fetch author profile + const authorProfileResult = await this.profileService.getProfile( + fullCollection.authorId.value, + ); + if (authorProfileResult.isErr()) { + throw new Error( + `Failed to fetch author profile: ${authorProfileResult.error.message}`, + ); + } + const authorProfile = authorProfileResult.value; + + return { + id: collection.id, + uri: collection.uri, + name: collection.name, + description: collection.description, + author: { + id: authorProfile.id, + name: authorProfile.name, + handle: authorProfile.handle, + avatarUrl: authorProfile.avatarUrl, + description: authorProfile.bio, + }, + cardCount: fullCollection.cardCount, + createdAt: fullCollection.createdAt.toISOString(), + updatedAt: fullCollection.updatedAt.toISOString(), + }; + }), + ); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } } } diff --git a/src/modules/cards/domain/ICardQueryRepository.ts b/src/modules/cards/domain/ICardQueryRepository.ts index e66b6734..31ea0536 100644 --- a/src/modules/cards/domain/ICardQueryRepository.ts +++ b/src/modules/cards/domain/ICardQueryRepository.ts @@ -129,6 +129,11 @@ export interface ICardQueryRepository { callingUserId?: string, ): Promise; + getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise; + getLibrariesForCard(cardId: string): Promise; getLibrariesForUrl( diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 19c7f75c..4c75b860 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -58,6 +58,13 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { return this.urlCardQueryService.getUrlCardView(cardId, callingUserId); } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); + } + async getLibrariesForCard(cardId: string): Promise { return this.libraryQueryService.getLibrariesForCard(cardId); } diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index b8f800d2..eeb655d9 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -471,6 +471,115 @@ export class UrlCardQueryService { } } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + try { + // Get the URL card + const cardQuery = this.db + .select({ + id: cards.id, + type: cards.type, + authorId: cards.authorId, + url: cards.url, + contentData: cards.contentData, + libraryCount: cards.libraryCount, + createdAt: cards.createdAt, + updatedAt: cards.updatedAt, + }) + .from(cards) + .where(and(eq(cards.id, cardId), eq(cards.type, CardTypeEnum.URL))); + + const cardResult = await cardQuery; + + if (cardResult.length === 0) { + return null; + } + + const card = cardResult[0]!; + + // Get note card for this URL card (same user, parentCardId matches, type = NOTE) + const noteQuery = this.db + .select({ + id: cards.id, + parentCardId: cards.parentCardId, + contentData: cards.contentData, + }) + .from(cards) + .where( + and( + eq(cards.type, CardTypeEnum.NOTE), + eq(cards.parentCardId, cardId), + eq(cards.authorId, card.authorId), // Only notes by the same author + ), + ) + .limit(1); // Only get the first note if multiple exist + + const noteResult = await noteQuery; + const note = noteResult.length > 0 ? noteResult[0] : undefined; + + // Get urlLibraryCount for this URL (count of unique users who have cards with this URL) + const urlLibraryCountQuery = this.db + .select({ + count: countDistinct(libraryMemberships.userId), + }) + .from(cards) + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) + .where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, card.url))); + + const urlLibraryCountResult = await urlLibraryCountQuery; + const urlLibraryCount = urlLibraryCountResult[0]?.count || 0; + + // Get urlInLibrary if callingUserId is provided + let urlInLibrary: boolean | undefined; + if (callingUserId) { + // Check if the calling user has any card with this URL + const urlInLibraryQuery = this.db + .select({ + id: cards.id, + }) + .from(cards) + .where( + and( + eq(cards.authorId, callingUserId), + eq(cards.type, CardTypeEnum.URL), + eq(cards.url, card.url), + ), + ) + .limit(1); + + const urlInLibraryResult = await urlInLibraryQuery; + urlInLibrary = urlInLibraryResult.length > 0; + } + + // Create raw card data for mapping + const rawCardData = { + id: card.id, + authorId: card.authorId, + url: card.url || '', + contentData: card.contentData, + libraryCount: card.libraryCount, + urlLibraryCount, + urlInLibrary, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + note: note + ? { + id: note.id, + contentData: note.contentData, + } + : undefined, + }; + + // Use CardMapper to transform to UrlCardView (without collections) + return CardMapper.toCollectionCardQueryResult(rawCardData); + } catch (error) { + console.error('Error in getUrlCardBasic:', error); + throw error; + } + } + private getSortColumn(sortBy: CardSortField) { switch (sortBy) { case CardSortField.CREATED_AT: diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts new file mode 100644 index 00000000..005adb80 --- /dev/null +++ b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts @@ -0,0 +1,205 @@ +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql'; +import postgres from 'postgres'; +import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository'; +import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +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 { CardBuilder } from '../utils/builders/CardBuilder'; +import { URL } from '../../domain/value-objects/URL'; +import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; +import { createTestSchema } from '../test-utils/createTestSchema'; +import { CardTypeEnum } from '../../domain/value-objects/CardType'; + +describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { + let container: StartedPostgreSqlContainer; + let db: PostgresJsDatabase; + let queryRepository: DrizzleCardQueryRepository; + 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 DrizzleCardQueryRepository(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(libraryMemberships); + await db.delete(cards); + await db.delete(publishedRecords); + }); + + describe('getUrlCardBasic', () => { + it('should return null when card does not exist', async () => { + const nonExistentCardId = new UniqueEntityID().toString(); + + const result = await queryRepository.getUrlCardBasic(nonExistentCardId); + + expect(result).toBeNull(); + }); + + it('should return null when card exists but is not a URL card', async () => { + // Create a note card + const noteCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withNoteCard('This is a note') + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + noteCard.cardId.getStringValue(), + ); + + expect(result).toBeNull(); + }); + + it('should return URL card with basic metadata', async () => { + // Create URL card with metadata + const url = URL.create('https://example.com/article').unwrap(); + const urlMetadata = UrlMetadata.create({ + url: url.value, + title: 'Test Article', + description: 'A test article description', + author: 'John Doe', + imageUrl: 'https://example.com/image.jpg', + }).unwrap(); + + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url, urlMetadata) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.id).toBe(urlCard.cardId.getStringValue()); + expect(result?.type).toBe(CardTypeEnum.URL); + expect(result?.url).toBe(url.value); + expect(result?.cardContent.title).toBe('Test Article'); + expect(result?.cardContent.description).toBe('A test article description'); + expect(result?.cardContent.author).toBe('John Doe'); + expect(result?.cardContent.thumbnailUrl).toBe('https://example.com/image.jpg'); + expect(result?.note).toBeUndefined(); + }); + + it('should include connected note card by the same author', async () => { + // Create URL card + const url = URL.create('https://example.com/article-with-note').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + // Create connected note card by the same author + const noteCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withNoteCard('This is my note about the article') + .withParentCard(urlCard.cardId) + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.note).toBeDefined(); + expect(result?.note?.id).toBe(noteCard.cardId.getStringValue()); + expect(result?.note?.text).toBe('This is my note about the article'); + }); + + it('should NOT include note card by a different author', async () => { + // Create URL card by first author + const url = URL.create('https://example.com/article-different-author').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + // Create connected note card by a DIFFERENT author + const noteCard = new CardBuilder() + .withCuratorId(otherCuratorId.value) // Different author + .withNoteCard('This is someone elses note') + .withParentCard(urlCard.cardId) + .buildOrThrow(); + + await cardRepository.save(noteCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.note).toBeUndefined(); // Should not include note from different author + }); + + it('should handle URL card without metadata', async () => { + // Create URL card without metadata + const url = URL.create('https://example.com/minimal').unwrap(); + const urlCard = new CardBuilder() + .withCuratorId(curatorId.value) + .withUrlCard(url) + .buildOrThrow(); + + await cardRepository.save(urlCard); + + const result = await queryRepository.getUrlCardBasic( + urlCard.cardId.getStringValue(), + ); + + expect(result).toBeDefined(); + expect(result?.id).toBe(urlCard.cardId.getStringValue()); + expect(result?.type).toBe(CardTypeEnum.URL); + expect(result?.url).toBe(url.value); + expect(result?.cardContent.title).toBeUndefined(); + expect(result?.cardContent.description).toBeUndefined(); + expect(result?.cardContent.author).toBeUndefined(); + expect(result?.cardContent.thumbnailUrl).toBeUndefined(); + expect(result?.note).toBeUndefined(); + }); + }); +}); diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 905f32ad..8eb4276b 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -192,7 +192,7 @@ export interface GetGlobalFeedResponse { } export interface GetUrlStatusForMyLibraryResponse { - cardId?: string; + card?: UrlCard; collections?: Collection[]; } -- 2.54.0 From 7bcd0054f0e0486f370e3cd54aff591810823591 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:44:47 -0700 Subject: [PATCH 03/11] feat: implement getUrlCardBasic method in card query repository Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../query-services/UrlCardQueryService.ts | 1 + .../utils/InMemoryCardQueryRepository.ts | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index eeb655d9..807378b5 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -1,4 +1,5 @@ import { eq, desc, asc, count, countDistinct, inArray, and } from 'drizzle-orm'; +import { UrlCardView } from '../../../domain/ICardQueryRepository'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { CardQueryOptions, diff --git a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts index c7c65c07..e8948b24 100644 --- a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts @@ -4,6 +4,7 @@ import { UrlCardQueryResultDTO, CollectionCardQueryResultDTO, UrlCardViewDTO, + UrlCardView, PaginatedQueryResult, CardSortField, SortOrder, @@ -349,6 +350,62 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { }; } + async getUrlCardBasic( + cardId: string, + callingUserId?: string, + ): Promise { + const allCards = this.cardRepository.getAllCards(); + const card = allCards.find((c) => c.cardId.getStringValue() === cardId); + if (!card || !card.isUrlCard) { + return null; + } + + // Find note card by the same author with matching parent card ID + const noteCard = allCards.find( + (c) => + c.type.value === 'NOTE' && + c.parentCardId?.equals(card.cardId) && + c.curatorId.value === card.curatorId.value, // Only notes by the same author + ); + + const note = noteCard + ? { + id: noteCard.cardId.getStringValue(), + text: noteCard.content.noteContent?.text || '', + } + : undefined; + + // Compute urlInLibrary if callingUserId is provided + const urlInLibrary = callingUserId + ? this.isUrlInUserLibrary( + card.content.urlContent!.url.value, + callingUserId, + ) + : undefined; + + return { + id: card.cardId.getStringValue(), + type: CardTypeEnum.URL, + url: card.content.urlContent!.url.value, + cardContent: { + url: card.content.urlContent!.url.value, + title: card.content.urlContent!.metadata?.title, + description: card.content.urlContent!.metadata?.description, + author: card.content.urlContent!.metadata?.author, + thumbnailUrl: card.content.urlContent!.metadata?.imageUrl, + }, + libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), + urlLibraryCount: this.getUrlLibraryCount( + card.content.urlContent!.url.value, + ), + urlInLibrary, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + authorId: card.curatorId.value, + note, + }; + } + async getLibrariesForCard(cardId: string): Promise { const allCards = this.cardRepository.getAllCards(); const card = allCards.find((c) => c.cardId.getStringValue() === cardId); -- 2.54.0 From efa8fa3bbd7184ae93b08093dd11426211f493af Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:45:52 -0700 Subject: [PATCH 04/11] test: update GetUrlStatusForMyLibraryUseCase tests with new constructor params Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 01932ed3..5618f5c6 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -1,5 +1,6 @@ import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; +import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; import { FakeCardPublisher } from '../utils/FakeCardPublisher'; @@ -18,6 +19,7 @@ import { ICardRepository } from '../../domain/ICardRepository'; describe('GetUrlStatusForMyLibraryUseCase', () => { let useCase: GetUrlStatusForMyLibraryUseCase; let cardRepository: InMemoryCardRepository; + let cardQueryRepository: InMemoryCardQueryRepository; let collectionRepository: InMemoryCollectionRepository; let collectionQueryRepository: InMemoryCollectionQueryRepository; let cardPublisher: FakeCardPublisher; @@ -30,6 +32,10 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { beforeEach(() => { cardRepository = InMemoryCardRepository.getInstance(); collectionRepository = InMemoryCollectionRepository.getInstance(); + cardQueryRepository = new InMemoryCardQueryRepository( + cardRepository, + collectionRepository, + ); collectionQueryRepository = new InMemoryCollectionQueryRepository( collectionRepository, ); @@ -40,6 +46,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { useCase = new GetUrlStatusForMyLibraryUseCase( cardRepository, + cardQueryRepository, collectionQueryRepository, collectionRepository, profileService, @@ -69,6 +76,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { afterEach(() => { cardRepository.clear(); + cardQueryRepository.clear(); collectionRepository.clear(); collectionQueryRepository.clear(); cardPublisher.clear(); @@ -527,6 +535,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const errorUseCase = new GetUrlStatusForMyLibraryUseCase( errorCardRepository, + cardQueryRepository, collectionQueryRepository, collectionRepository, profileService, @@ -581,6 +590,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { const errorUseCase = new GetUrlStatusForMyLibraryUseCase( cardRepository, + cardQueryRepository, errorCollectionQueryRepository, collectionRepository, profileService, -- 2.54.0 From d5c091ddcdb8852a6f21b309740129ed650715ce Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:47:23 -0700 Subject: [PATCH 05/11] test: update tests to use new card property in GetUrlStatusForMyLibraryUseCase Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../GetUrlStatusForMyLibraryUseCase.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 5618f5c6..008c5ed1 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -221,7 +221,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(2); // Verify collection details @@ -297,7 +298,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(0); }); @@ -316,7 +318,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBeUndefined(); + expect(response.card).toBeUndefined(); expect(response.collections).toBeUndefined(); }); @@ -400,7 +402,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card1.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card1.cardId.getStringValue()); expect(response.collections).toHaveLength(0); // No collections for first user }); @@ -481,7 +484,8 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { expect(result.isOk()).toBe(true); const response = result.unwrap(); - expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.card).toBeDefined(); + expect(response.card?.id).toBe(card.cardId.getStringValue()); expect(response.collections).toHaveLength(1); expect(response.collections?.[0]?.name).toBe('My Collection'); expect(response.collections?.[0]?.id).toBe( -- 2.54.0 From 98147f8154e80ff95d65b198d7e50dfc2041b130 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:48:52 -0700 Subject: [PATCH 06/11] fix: add missing `getUrlCardBasic` method to mock repositories and correct DTO type Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../infrastructure/repositories/DrizzleCardQueryRepository.ts | 2 +- .../cards/tests/application/GetCollectionPageUseCase.test.ts | 1 + .../cards/tests/application/GetLibrariesForUrlUseCase.test.ts | 1 + .../cards/tests/application/GetMyUrlCardsUseCase.test.ts | 1 + .../cards/tests/application/GetUrlCardViewUseCase.test.ts | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 4c75b860..93f89ec4 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -61,7 +61,7 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { async getUrlCardBasic( cardId: string, callingUserId?: string, - ): Promise { + ): Promise { return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); } diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index 6b3fd03d..d91eb734 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -755,6 +755,7 @@ describe('GetCollectionPageUseCase', () => { .fn() .mockRejectedValue(new Error('Query failed')), getUrlCardView: jest.fn(), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), diff --git a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts index e3119bc0..c586ee2f 100644 --- a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts @@ -363,6 +363,7 @@ describe('GetLibrariesForUrlUseCase', () => { getUrlCardsOfUser: jest.fn(), getCardsInCollection: jest.fn(), getUrlCardView: jest.fn(), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest .fn() diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index aac80b19..a2feed6e 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -626,6 +626,7 @@ describe('GetUrlCardsUseCase', () => { getUrlCardView: jest .fn() .mockRejectedValue(new Error('Database connection failed')), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), diff --git a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts index 1752c462..de758bc8 100644 --- a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts @@ -478,6 +478,7 @@ describe('GetUrlCardViewUseCase', () => { getUrlCardView: jest .fn() .mockRejectedValue(new Error('Database connection failed')), + getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), -- 2.54.0 From e628a8398fb624746b6049f711f1563632b2a18d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:50:56 -0700 Subject: [PATCH 07/11] include query repo in use case factory --- src/shared/infrastructure/http/factories/UseCaseFactory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index bd9da335..e68e6ea6 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -199,6 +199,7 @@ export class UseCaseFactory { ), getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( repositories.cardRepository, + repositories.cardQueryRepository, repositories.collectionQueryRepository, repositories.collectionRepository, services.profileService, @@ -224,7 +225,6 @@ export class UseCaseFactory { services.profileService, repositories.cardQueryRepository, repositories.collectionRepository, - services.identityResolutionService, ), addActivityToFeedUseCase: new AddActivityToFeedUseCase( services.feedService, -- 2.54.0 From 178239ce01882192db4a778aba831f58a39d8cb2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:51:13 -0700 Subject: [PATCH 08/11] minor type update to repo implementation --- .../infrastructure/repositories/DrizzleCardQueryRepository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 93f89ec4..0c141e2e 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -8,6 +8,7 @@ import { UrlCardViewDTO, LibraryForUrlDTO, NoteCardForUrlRawDTO, + UrlCardView, } from '../../domain/ICardQueryRepository'; import { UrlCardQueryService } from './query-services/UrlCardQueryService'; import { CollectionCardQueryService } from './query-services/CollectionCardQueryService'; @@ -61,7 +62,7 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { async getUrlCardBasic( cardId: string, callingUserId?: string, - ): Promise { + ): Promise { return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); } -- 2.54.0 From 933673828fb090a33244bcea08d0ee474e51c3b3 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 15:53:28 -0700 Subject: [PATCH 09/11] formatting and linting --- .../queries/GetUrlStatusForMyLibraryUseCase.ts | 11 +++++++---- ...eryRepository.getUrlCardBasic.integration.test.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index 571cf10a..b15663f5 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -101,7 +101,9 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< ); if (authorProfileResult.isErr()) { - return err(AppError.UnexpectedError.create(authorProfileResult.error)); + return err( + AppError.UnexpectedError.create(authorProfileResult.error), + ); } const authorProfile = authorProfileResult.value; @@ -154,9 +156,10 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< const fullCollection = collectionResult.value; // Fetch author profile - const authorProfileResult = await this.profileService.getProfile( - fullCollection.authorId.value, - ); + const authorProfileResult = + await this.profileService.getProfile( + fullCollection.authorId.value, + ); if (authorProfileResult.isErr()) { throw new Error( `Failed to fetch author profile: ${authorProfileResult.error.message}`, diff --git a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts index 005adb80..04db9477 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts @@ -115,9 +115,13 @@ describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { expect(result?.type).toBe(CardTypeEnum.URL); expect(result?.url).toBe(url.value); expect(result?.cardContent.title).toBe('Test Article'); - expect(result?.cardContent.description).toBe('A test article description'); + expect(result?.cardContent.description).toBe( + 'A test article description', + ); expect(result?.cardContent.author).toBe('John Doe'); - expect(result?.cardContent.thumbnailUrl).toBe('https://example.com/image.jpg'); + expect(result?.cardContent.thumbnailUrl).toBe( + 'https://example.com/image.jpg', + ); expect(result?.note).toBeUndefined(); }); @@ -152,7 +156,9 @@ describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { it('should NOT include note card by a different author', async () => { // Create URL card by first author - const url = URL.create('https://example.com/article-different-author').unwrap(); + const url = URL.create( + 'https://example.com/article-different-author', + ).unwrap(); const urlCard = new CardBuilder() .withCuratorId(curatorId.value) .withUrlCard(url) -- 2.54.0 From 55a2ff399b5602dd6bf40ab701ff4ff0bc4063c8 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 16:06:13 -0700 Subject: [PATCH 10/11] refactor: update cardId references to card.id and add missing import Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../cards/components/addCardToModal/AddCardToModal.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index b6fa58f4..4c56331b 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -11,6 +11,7 @@ import CollectionSelector from '@/features/collections/components/collectionSele import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; import useAddCard from '../../lib/mutations/useAddCard'; +import { SelectableCollectionItem } from '@/features/collections/types'; interface Props { isOpen: boolean; @@ -24,7 +25,7 @@ interface Props { export default function AddCardToModal(props: Props) { const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); - const isMyCard = props.cardId === cardStatus.data.cardId; + const isMyCard = props.cardId === cardStatus.data.card?.id; const [note, setNote] = useState(props.note); const { data, error } = useMyCollections(); @@ -58,13 +59,13 @@ export default function AddCardToModal(props: Props) { const hasAdded = addedCollections.length > 0; const hasRemoved = removedCollections.length > 0; - if (cardStatus.data.cardId && !hasNoteChanged && !hasAdded && !hasRemoved) { + if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) { props.onClose(); return; } // if the card is not in library, add it instead of updating - if (!cardStatus.data.cardId) { + if (!cardStatus.data.card) { addCard.mutate( { url: props.cardContent.url, @@ -91,7 +92,7 @@ export default function AddCardToModal(props: Props) { note?: string; addToCollectionIds?: string[]; removeFromCollectionIds?: string[]; - } = { cardId: cardStatus.data.cardId }; + } = { cardId: cardStatus.data.card!.id }; if (hasNoteChanged) updatedCardPayload.note = trimmedNote; if (hasAdded) -- 2.54.0 From a8531bc18a8e1535ab8bb6e593d93e3573b02479 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 24 Oct 2025 16:10:35 -0700 Subject: [PATCH 11/11] fix build error and unecessary imports --- .../cards/components/addCardToModal/AddCardToModal.tsx | 3 +-- src/webapp/hooks/useUrlMetadata.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx index 4c56331b..f53e8f37 100644 --- a/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx +++ b/src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx @@ -1,6 +1,6 @@ import type { UrlCard } from '@/api-client'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; -import { Anchor, Modal, Stack, Text } from '@mantine/core'; +import { Modal, Stack, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { Suspense, useState } from 'react'; import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; @@ -11,7 +11,6 @@ import CollectionSelector from '@/features/collections/components/collectionSele import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; import useAddCard from '../../lib/mutations/useAddCard'; -import { SelectableCollectionItem } from '@/features/collections/types'; interface Props { isOpen: boolean; diff --git a/src/webapp/hooks/useUrlMetadata.ts b/src/webapp/hooks/useUrlMetadata.ts index 4d914391..f292cdca 100644 --- a/src/webapp/hooks/useUrlMetadata.ts +++ b/src/webapp/hooks/useUrlMetadata.ts @@ -46,7 +46,7 @@ export function useUrlMetadata({ }); // If there's an existing card, fetch its collections - if (existingCard.cardId) { + if (existingCard.card) { try { setExistingCardCollections(existingCard.collections); } catch (cardErr: any) {