diff --git a/src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts index 96dccac9..d838b11e 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts @@ -22,6 +22,7 @@ export interface GetUrlCardsQuery { sortBy?: CardSortField; sortOrder?: SortOrder; urlType?: UrlType; + uncollected?: boolean; } // Enriched data for the final use case result @@ -95,6 +96,7 @@ export class GetUrlCardsUseCase sortBy, sortOrder, urlType, + uncollected: query.uncollected, }, query.callingUserId, ); diff --git a/src/modules/cards/domain/ICardQueryRepository.ts b/src/modules/cards/domain/ICardQueryRepository.ts index 25638e64..60d18f88 100644 --- a/src/modules/cards/domain/ICardQueryRepository.ts +++ b/src/modules/cards/domain/ICardQueryRepository.ts @@ -9,6 +9,7 @@ export interface CardQueryOptions { sortBy: CardSortField; sortOrder: SortOrder; urlType?: UrlType; + uncollected?: boolean; } export interface SearchUrlsOptions { diff --git a/src/modules/cards/infrastructure/http/controllers/GetMyUrlCardsController.ts b/src/modules/cards/infrastructure/http/controllers/GetMyUrlCardsController.ts index 6ecd6557..346bb608 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetMyUrlCardsController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetMyUrlCardsController.ts @@ -12,7 +12,8 @@ export class GetMyUrlCardsController extends Controller { async executeImpl(req: AuthenticatedRequest, res: Response): Promise { try { const callerDid = req.did; - const { page, limit, sortBy, sortOrder, urlType } = req.query; + const { page, limit, sortBy, sortOrder, urlType, uncollected } = + req.query; if (!callerDid) { return this.unauthorized(res); @@ -26,6 +27,7 @@ export class GetMyUrlCardsController extends Controller { sortBy: sortBy as CardSortField, sortOrder: sortOrder as SortOrder, urlType: urlType as any, + uncollected: uncollected === 'true', }); if (result.isErr()) { diff --git a/src/modules/cards/infrastructure/http/controllers/GetUserUrlCardsController.ts b/src/modules/cards/infrastructure/http/controllers/GetUserUrlCardsController.ts index 325a32a8..4147ab3d 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetUserUrlCardsController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetUserUrlCardsController.ts @@ -12,7 +12,8 @@ export class GetUserUrlCardsController extends Controller { async executeImpl(req: AuthenticatedRequest, res: Response): Promise { try { const { identifier } = req.params; - const { page, limit, sortBy, sortOrder, urlType } = req.query; + const { page, limit, sortBy, sortOrder, urlType, uncollected } = + req.query; const callerDid = req.did; if (!identifier) { @@ -27,6 +28,7 @@ export class GetUserUrlCardsController extends Controller { sortBy: sortBy as CardSortField, sortOrder: sortOrder as SortOrder, urlType: urlType as any, + uncollected: uncollected === 'true', }); if (result.isErr()) { diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index 560961e5..c89018ac 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -82,7 +82,29 @@ export class UrlCardQueryService { const allUrlCardsResult = await allUrlCardsQuery; - if (allUrlCardsResult.length === 0) { + // Filter out cards in collections if uncollected flag is set + let filteredUrlCards = allUrlCardsResult; + if (options.uncollected) { + // Get all cardIds that are in collections added by this user + const collectedCardIdsQuery = this.db + .select({ + cardId: collectionCards.cardId, + }) + .from(collectionCards) + .where(eq(collectionCards.addedBy, userId)); + + const collectedCardIdsResult = await collectedCardIdsQuery; + const collectedCardIds = new Set( + collectedCardIdsResult.map((row) => row.cardId), + ); + + // Filter out cards that are in collections + filteredUrlCards = allUrlCardsResult.filter( + (card) => !collectedCardIds.has(card.id), + ); + } + + if (filteredUrlCards.length === 0) { return { items: [], totalCount: 0, @@ -90,7 +112,7 @@ export class UrlCardQueryService { }; } - const urls = allUrlCardsResult.map((card) => card.url || ''); + const urls = filteredUrlCards.map((card) => card.url || ''); // Calculate urlLibraryCount for each URL const urlLibraryCountsQuery = this.db @@ -119,7 +141,7 @@ export class UrlCardQueryService { }); // Combine cards with their urlLibraryCount - const cardsWithUrlLibraryCount = allUrlCardsResult.map((card) => ({ + const cardsWithUrlLibraryCount = filteredUrlCards.map((card) => ({ ...card, urlLibraryCount: urlLibraryCountMap.get(card.url || '') || 0, })); @@ -151,7 +173,7 @@ export class UrlCardQueryService { if (urlCardsResult.length === 0) { return { items: [], - totalCount: allUrlCardsResult.length, + totalCount: filteredUrlCards.length, hasMore: false, }; } @@ -329,7 +351,7 @@ export class UrlCardQueryService { ); } - const totalCount = allUrlCardsResult.length; + const totalCount = filteredUrlCards.length; const hasMore = startIndex + urlCardsResult.length < totalCount; // Combine the data @@ -394,6 +416,18 @@ export class UrlCardQueryService { } // Standard sorting for other fields + // Add uncollected filter for standard sorting path + const standardWhereConditions = [...whereConditions]; + if (options.uncollected) { + standardWhereConditions.push( + sql`NOT EXISTS ( + SELECT 1 FROM collection_cards + WHERE collection_cards.card_id = ${cards.id} + AND collection_cards.added_by = ${userId} + )`, + ); + } + const urlCardsQuery = this.db .select({ id: cards.id, @@ -410,7 +444,7 @@ export class UrlCardQueryService { publishedRecords, eq(cards.publishedRecordId, publishedRecords.id), ) - .where(and(...whereConditions)) + .where(and(...standardWhereConditions)) .orderBy(orderDirection(this.getSortColumn(sortBy))) .limit(limit) .offset(offset); @@ -621,7 +655,7 @@ export class UrlCardQueryService { const totalCountResult = await this.db .select({ count: count() }) .from(cards) - .where(and(...whereConditions)); + .where(and(...standardWhereConditions)); const totalCount = totalCountResult[0]?.count || 0; const hasMore = offset + urlCardsResult.length < totalCount; diff --git a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts index fabca572..26da3605 100644 --- a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts @@ -51,6 +51,26 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { ); } + // Filter out cards in collections if uncollected flag is set + if (options.uncollected) { + const allCollections = this.collectionRepository.getAllCollections(); + const collectedCardIds = new Set(); + + // Find all cards that the user added to any collection + for (const collection of allCollections) { + for (const cardLink of collection.cardLinks) { + if (cardLink.addedBy.value === userId) { + collectedCardIds.add(cardLink.cardId.getStringValue()); + } + } + } + + // Filter out cards that are in collections + userCards = userCards.filter( + (card) => !collectedCardIds.has(card.cardId.getStringValue()), + ); + } + const userCardResults = await Promise.all( userCards.map((card) => this.cardToUrlCardQueryResult(card, callingUserId), diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index a098ddc6..e524d2b9 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -98,11 +98,13 @@ export interface DeleteCollectionRequest { // Query parameters export interface GetMyUrlCardsParams extends PaginatedSortedParams { urlType?: UrlType; + uncollected?: boolean; } export interface GetUrlCardsParams extends PaginatedSortedParams { identifier: string; // Can be DID or handle urlType?: UrlType; + uncollected?: boolean; } export interface GetCollectionPageParams extends PaginatedSortedParams { diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index f8662046..3ca9b815 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -83,6 +83,7 @@ export class QueryClient extends BaseClient { if (params?.sortBy) searchParams.set('sortBy', params.sortBy); if (params?.sortOrder) searchParams.set('sortOrder', params.sortOrder); if (params?.urlType) searchParams.set('urlType', params.urlType); + if (params?.uncollected) searchParams.set('uncollected', 'true'); const queryString = searchParams.toString(); const endpoint = queryString @@ -101,6 +102,7 @@ export class QueryClient extends BaseClient { if (params.sortBy) searchParams.set('sortBy', params.sortBy); if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); if (params.urlType) searchParams.set('urlType', params.urlType); + if (params.uncollected) searchParams.set('uncollected', 'true'); const queryString = searchParams.toString(); const endpoint = queryString