From 5675949ce507fd4a9d5fd2f5fb4e8a6e9539f6e7 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 12 Mar 2026 18:25:49 -0700 Subject: [PATCH] include connection count in more use cases and improve query performance for fetching batch url stats --- .../GetBackwardConnectionsForUrlUseCase.ts | 8 + .../useCases/queries/GetConnectionsUseCase.ts | 12 + .../GetForwardConnectionsForUrlUseCase.ts | 8 + .../useCases/queries/SearchUrlsUseCase.ts | 2 + .../cards/domain/ICardQueryRepository.ts | 4 + .../repositories/mappers/CardMapper.ts | 12 + .../query-services/UrlCardQueryService.ts | 302 +++++++++++++++++- .../utils/InMemoryCardQueryRepository.ts | 154 +++++++-- .../queries/GetFollowingFeedUseCase.ts | 94 +++--- .../useCases/queries/GetGlobalFeedUseCase.ts | 2 + .../queries/GetMyNotificationsUseCase.ts | 2 + .../domain/INotificationRepository.ts | 2 + .../DrizzleNotificationRepository.ts | 76 ++++- .../search/domain/services/SearchService.ts | 141 ++++---- .../http/factories/RepositoryFactory.ts | 1 + src/types/src/api/responses.ts | 4 + 16 files changed, 674 insertions(+), 150 deletions(-) diff --git a/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts index d984ac6d..f6d7a722 100644 --- a/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts @@ -38,6 +38,8 @@ export interface ConnectionForUrlView { metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; }; } @@ -175,6 +177,8 @@ export class GetBackwardConnectionsForUrlUseCase metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; } >(); @@ -200,6 +204,8 @@ export class GetBackwardConnectionsForUrlUseCase metadata, urlLibraryCount: urlInfo.urlLibraryCount, urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, }); } }); @@ -233,6 +239,8 @@ export class GetBackwardConnectionsForUrlUseCase metadata: urlData.metadata, urlLibraryCount: urlData.urlLibraryCount, urlInLibrary: urlData.urlInLibrary, + urlConnectionCount: urlData.urlConnectionCount, + urlIsConnected: urlData.urlIsConnected, }, }; }, diff --git a/src/modules/cards/application/useCases/queries/GetConnectionsUseCase.ts b/src/modules/cards/application/useCases/queries/GetConnectionsUseCase.ts index ff938f3b..b1545526 100644 --- a/src/modules/cards/application/useCases/queries/GetConnectionsUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetConnectionsUseCase.ts @@ -37,12 +37,16 @@ export interface ConnectionView { metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; }; target: { url: string; metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; }; } @@ -164,6 +168,8 @@ export class GetConnectionsUseCase metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; } >(); @@ -189,6 +195,8 @@ export class GetConnectionsUseCase metadata, urlLibraryCount: urlInfo.urlLibraryCount, urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, }); } }); @@ -218,12 +226,16 @@ export class GetConnectionsUseCase metadata: sourceData.metadata, urlLibraryCount: sourceData.urlLibraryCount, urlInLibrary: sourceData.urlInLibrary, + urlConnectionCount: sourceData.urlConnectionCount, + urlIsConnected: sourceData.urlIsConnected, }, target: { url: item.targetUrl, metadata: targetData.metadata, urlLibraryCount: targetData.urlLibraryCount, urlInLibrary: targetData.urlInLibrary, + urlConnectionCount: targetData.urlConnectionCount, + urlIsConnected: targetData.urlIsConnected, }, }; }); diff --git a/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts index 2a2f951c..b98c6825 100644 --- a/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts @@ -38,6 +38,8 @@ export interface ConnectionForUrlView { metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; }; } @@ -174,6 +176,8 @@ export class GetForwardConnectionsForUrlUseCase metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; } >(); @@ -199,6 +203,8 @@ export class GetForwardConnectionsForUrlUseCase metadata, urlLibraryCount: urlInfo.urlLibraryCount, urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, }); } }); @@ -232,6 +238,8 @@ export class GetForwardConnectionsForUrlUseCase metadata: urlData.metadata, urlLibraryCount: urlData.urlLibraryCount, urlInLibrary: urlData.urlInLibrary, + urlConnectionCount: urlData.urlConnectionCount, + urlIsConnected: urlData.urlIsConnected, }, }; }, diff --git a/src/modules/cards/application/useCases/queries/SearchUrlsUseCase.ts b/src/modules/cards/application/useCases/queries/SearchUrlsUseCase.ts index 16efd268..1588689c 100644 --- a/src/modules/cards/application/useCases/queries/SearchUrlsUseCase.ts +++ b/src/modules/cards/application/useCases/queries/SearchUrlsUseCase.ts @@ -92,6 +92,8 @@ export class SearchUrlsUseCase metadata, urlLibraryCount: urlInfo.urlLibraryCount, urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, }; return urlView; diff --git a/src/modules/cards/domain/ICardQueryRepository.ts b/src/modules/cards/domain/ICardQueryRepository.ts index 78db4913..53fca0d7 100644 --- a/src/modules/cards/domain/ICardQueryRepository.ts +++ b/src/modules/cards/domain/ICardQueryRepository.ts @@ -64,6 +64,8 @@ export interface UrlCardView { libraryCount: number; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; createdAt: Date; updatedAt: Date; authorId: string; // NEW - needed to enrich with author profile @@ -152,6 +154,8 @@ export interface NoteCardForUrlDTO { export interface UrlLibraryInfo { urlLibraryCount: number; // distinct users with this URL urlInLibrary?: boolean; // whether calling user has it + urlConnectionCount?: number; // total connections involving this URL + urlIsConnected?: boolean; // whether calling user has any connection with this URL metadata: { url: string; title?: string; diff --git a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts index 1c16a307..c2b2a4e8 100644 --- a/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts +++ b/src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts @@ -85,6 +85,8 @@ export interface RawUrlCardData { libraryCount: number; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; createdAt: Date; updatedAt: Date; collections: { @@ -408,6 +410,8 @@ export class CardMapper { libraryCount: raw.libraryCount, urlLibraryCount: raw.urlLibraryCount, urlInLibrary: raw.urlInLibrary, + urlConnectionCount: raw.urlConnectionCount, + urlIsConnected: raw.urlIsConnected, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, @@ -425,6 +429,8 @@ export class CardMapper { libraryCount: number; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; createdAt: Date; updatedAt: Date; note?: { @@ -468,6 +474,8 @@ export class CardMapper { libraryCount: raw.libraryCount, urlLibraryCount: raw.urlLibraryCount, urlInLibrary: raw.urlInLibrary, + urlConnectionCount: raw.urlConnectionCount, + urlIsConnected: raw.urlIsConnected, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, @@ -485,6 +493,8 @@ export class CardMapper { libraryCount: number; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; createdAt: Date; updatedAt: Date; inLibraries: { @@ -537,6 +547,8 @@ export class CardMapper { libraryCount: raw.libraryCount, urlLibraryCount: raw.urlLibraryCount, urlInLibrary: raw.urlInLibrary, + urlConnectionCount: raw.urlConnectionCount, + urlIsConnected: raw.urlIsConnected, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index 3abf933d..e5396003 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -28,6 +28,7 @@ import { cards } from '../schema/card.sql'; import { collections, collectionCards } from '../schema/collection.sql'; import { libraryMemberships } from '../schema/libraryMembership.sql'; import { publishedRecords } from '../schema/publishedRecord.sql'; +import { connections } from '../schema/connection.sql'; import { CardMapper, RawUrlCardData } from '../mappers/CardMapper'; import { CardTypeEnum } from '../../../domain/value-objects/CardType'; @@ -221,6 +222,81 @@ export class UrlCardQueryService { }); } + // Get connection counts for each URL + const urlConnectionCountsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + count: count(), + }) + .from(connections) + .where( + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls_paginated), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls_paginated), + ), + ), + ) + .groupBy(sql`url`); + + const urlConnectionCountsResult = await urlConnectionCountsQuery; + + const urlConnectionCountMap = new Map(); + urlConnectionCountsResult.forEach((row) => { + if (row.url) { + urlConnectionCountMap.set( + row.url, + (urlConnectionCountMap.get(row.url) || 0) + Number(row.count), + ); + } + }); + + // Get URLs that calling user has connections with + let urlIsConnectedMap: Map | undefined; + if (callingUserId) { + urlIsConnectedMap = new Map(); + + const userConnectionsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + }) + .from(connections) + .where( + and( + eq(connections.curatorId, callingUserId), + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls_paginated), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls_paginated), + ), + ), + ), + ); + + const userConnectionsResult = await userConnectionsQuery; + + urls_paginated.forEach((url) => urlIsConnectedMap!.set(url, false)); + userConnectionsResult.forEach((row) => { + if (row.url) { + urlIsConnectedMap!.set(row.url, true); + } + }); + } + const totalCount = allUrlCardsResult.length; const hasMore = startIndex + urlCardsResult.length < totalCount; @@ -245,6 +321,11 @@ export class UrlCardQueryService { // Get urlInLibrary from the map (undefined if callingUserId not provided) const urlInLibrary = urlInLibraryMap?.get(card.url || ''); + // Get connection stats from the maps + const urlConnectionCount = + urlConnectionCountMap.get(card.url || '') || 0; + const urlIsConnected = urlIsConnectedMap?.get(card.url || ''); + return { id: card.id, authorId: card.authorId, @@ -254,6 +335,8 @@ export class UrlCardQueryService { libraryCount: card.libraryCount, urlLibraryCount, urlInLibrary, + urlConnectionCount, + urlIsConnected, createdAt: card.createdAt, updatedAt: card.updatedAt, collections: cardCollections, @@ -399,6 +482,81 @@ export class UrlCardQueryService { }); } + // Get connection counts for each URL + const urlConnectionCountsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + count: count(), + }) + .from(connections) + .where( + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ) + .groupBy(sql`url`); + + const urlConnectionCountsResult = await urlConnectionCountsQuery; + + const urlConnectionCountMap = new Map(); + urlConnectionCountsResult.forEach((row) => { + if (row.url) { + urlConnectionCountMap.set( + row.url, + (urlConnectionCountMap.get(row.url) || 0) + Number(row.count), + ); + } + }); + + // Get URLs that calling user has connections with + let urlIsConnectedMap: Map | undefined; + if (callingUserId) { + urlIsConnectedMap = new Map(); + + const userConnectionsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + }) + .from(connections) + .where( + and( + eq(connections.curatorId, callingUserId), + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ), + ); + + const userConnectionsResult = await userConnectionsQuery; + + urls.forEach((url) => urlIsConnectedMap!.set(url, false)); + userConnectionsResult.forEach((row) => { + if (row.url) { + urlIsConnectedMap!.set(row.url, true); + } + }); + } + // Get total count const totalCountResult = await this.db .select({ count: count() }) @@ -429,6 +587,11 @@ export class UrlCardQueryService { // Get urlInLibrary from the map (undefined if callingUserId not provided) const urlInLibrary = urlInLibraryMap?.get(card.url || ''); + // Get connection stats from the maps + const urlConnectionCount = + urlConnectionCountMap.get(card.url || '') || 0; + const urlIsConnected = urlIsConnectedMap?.get(card.url || ''); + return { id: card.id, authorId: card.authorId, @@ -438,6 +601,8 @@ export class UrlCardQueryService { libraryCount: card.libraryCount, urlLibraryCount, urlInLibrary, + urlConnectionCount, + urlIsConnected, createdAt: card.createdAt, updatedAt: card.updatedAt, collections: cardCollections, @@ -578,6 +743,58 @@ export class UrlCardQueryService { urlInLibrary = urlInLibraryResult.length > 0; } + // Get connection count for this URL + const urlConnectionCountQuery = this.db + .select({ + count: count(), + }) + .from(connections) + .where( + or( + and( + eq(connections.sourceType, 'URL'), + eq(connections.sourceValue, card.url || ''), + ), + and( + eq(connections.targetType, 'URL'), + eq(connections.targetValue, card.url || ''), + ), + ), + ); + + const urlConnectionCountResult = await urlConnectionCountQuery; + const urlConnectionCount = + Number(urlConnectionCountResult[0]?.count) || 0; + + // Get urlIsConnected if callingUserId is provided + let urlIsConnected: boolean | undefined; + if (callingUserId) { + const urlIsConnectedQuery = this.db + .select({ + id: connections.id, + }) + .from(connections) + .where( + and( + eq(connections.curatorId, callingUserId), + or( + and( + eq(connections.sourceType, 'URL'), + eq(connections.sourceValue, card.url || ''), + ), + and( + eq(connections.targetType, 'URL'), + eq(connections.targetValue, card.url || ''), + ), + ), + ), + ) + .limit(1); + + const urlIsConnectedResult = await urlIsConnectedQuery; + urlIsConnected = urlIsConnectedResult.length > 0; + } + // Map to DTO const urlCardView = CardMapper.toUrlCardViewDTO({ id: card.id, @@ -589,6 +806,8 @@ export class UrlCardQueryService { libraryCount: card.libraryCount, urlLibraryCount, urlInLibrary, + urlConnectionCount, + urlIsConnected, createdAt: card.createdAt, updatedAt: card.updatedAt, inLibraries: libraryResult.map((lib) => ({ userId: lib.userId })), @@ -1218,7 +1437,82 @@ export class UrlCardQueryService { }); } - // 3. Get sample card metadata for each URL (one card per URL) + // 3. Get connection counts for each URL (total connections where URL is source or target) + const urlConnectionCountsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + count: count(), + }) + .from(connections) + .where( + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ) + .groupBy(sql`url`); + + const urlConnectionCountsResult = await urlConnectionCountsQuery; + + // Build map of URL to connection count + const urlConnectionCountMap = new Map(); + urlConnectionCountsResult.forEach((row) => { + if (row.url) { + urlConnectionCountMap.set( + row.url, + (urlConnectionCountMap.get(row.url) || 0) + Number(row.count), + ); + } + }); + + // 4. Get URLs that calling user has connections with (if callingUserId provided) + let urlIsConnectedMap: Map | undefined; + if (callingUserId) { + urlIsConnectedMap = new Map(); + + const userConnectionsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + }) + .from(connections) + .where( + and( + eq(connections.curatorId, callingUserId), + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ), + ); + + const userConnectionsResult = await userConnectionsQuery; + + userConnectionsResult.forEach((row) => { + if (row.url) { + urlIsConnectedMap!.set(row.url, true); + } + }); + } + + // 5. Get sample card metadata for each URL (one card per URL) // We'll get the most recently updated card for each URL const sampleCardsQuery = this.db .select({ @@ -1239,12 +1533,14 @@ export class UrlCardQueryService { } }); - // 4. Build result map + // 6. Build result map const resultMap = new Map(); urls.forEach((url) => { const urlLibraryCount = urlLibraryCountMap.get(url) || 0; const urlInLibrary = urlInLibraryMap?.get(url); + const urlConnectionCount = urlConnectionCountMap.get(url) || 0; + const urlIsConnected = urlIsConnectedMap?.get(url); const contentData = sampleCardMap.get(url); // Build metadata from contentData or create minimal metadata @@ -1273,6 +1569,8 @@ export class UrlCardQueryService { resultMap.set(url, { urlLibraryCount, urlInLibrary, + urlConnectionCount, + urlIsConnected, metadata, }); }); diff --git a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts index d0579342..fabca572 100644 --- a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts @@ -17,14 +17,18 @@ import { import { CardTypeEnum } from '../../domain/value-objects/CardType'; import { InMemoryCardRepository } from './InMemoryCardRepository'; import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; +import { IConnectionRepository } from '../../domain/IConnectionRepository'; import { Card } from '../../domain/Card'; import { CollectionId } from '../../domain/value-objects/CollectionId'; import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { UrlOrCardId } from '../../domain/value-objects/UrlOrCardId'; +import { URL } from '../../domain/value-objects/URL'; export class InMemoryCardQueryRepository implements ICardQueryRepository { constructor( private cardRepository: InMemoryCardRepository, private collectionRepository: InMemoryCollectionRepository, + private connectionRepository?: IConnectionRepository, ) {} async getUrlCardsOfUser( @@ -47,8 +51,10 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { ); } - const userCardResults = userCards.map((card) => - this.cardToUrlCardQueryResult(card, callingUserId), + const userCardResults = await Promise.all( + userCards.map((card) => + this.cardToUrlCardQueryResult(card, callingUserId), + ), ); // Sort cards @@ -103,10 +109,10 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { return sorted; } - private cardToUrlCardQueryResult( + private async cardToUrlCardQueryResult( card: Card, callingUserId?: string, - ): UrlCardQueryResultDTO { + ): Promise { if (!card.isUrlCard || !card.content.urlContent) { throw new Error('Card is not a URL card'); } @@ -151,31 +157,36 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { } : undefined; + const url = card.content.urlContent.url.value; + // Compute urlInLibrary if callingUserId is provided const urlInLibrary = callingUserId - ? this.isUrlInUserLibrary( - card.content.urlContent.url.value, - callingUserId, - ) + ? this.isUrlInUserLibrary(url, callingUserId) + : undefined; + + // Get connection count and connection status + const urlConnectionCount = await this.getUrlConnectionCount(url); + const urlIsConnected = callingUserId + ? await this.isUrlConnectedByUser(url, callingUserId) : undefined; return { id: card.cardId.getStringValue(), type: CardTypeEnum.URL, uri: card.publishedRecordId?.uri, - url: card.content.urlContent.url.value, + url, cardContent: { - url: card.content.urlContent.url.value, + url, title: card.content.urlContent.metadata?.title, description: card.content.urlContent.metadata?.description, author: card.content.urlContent.metadata?.author, imageUrl: card.content.urlContent.metadata?.imageUrl, }, libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), - urlLibraryCount: this.getUrlLibraryCount( - card.content.urlContent.url.value, - ), + urlLibraryCount: this.getUrlLibraryCount(url), urlInLibrary, + urlConnectionCount, + urlIsConnected, createdAt: card.createdAt, updatedAt: card.updatedAt, authorId: card.curatorId.value, @@ -220,6 +231,79 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { ); } + private async getUrlConnectionCount(url: string): Promise { + if (!this.connectionRepository) { + return 0; + } + + try { + const urlResult = URL.create(url); + if (urlResult.isErr()) { + return 0; + } + + const urlOrCardIdResult = UrlOrCardId.createFromUrl(urlResult.value); + if (urlOrCardIdResult.isErr()) { + return 0; + } + + const urlOrCardId = urlOrCardIdResult.value; + + // Get connections where this URL is the source + const sourceConnectionsResult = + await this.connectionRepository.findBySource(urlOrCardId); + const sourceConnections = sourceConnectionsResult.isOk() + ? sourceConnectionsResult.value + : []; + + // Get connections where this URL is the target + const targetConnectionsResult = + await this.connectionRepository.findByTarget(urlOrCardId); + const targetConnections = targetConnectionsResult.isOk() + ? targetConnectionsResult.value + : []; + + return sourceConnections.length + targetConnections.length; + } catch (error) { + return 0; + } + } + + private async isUrlConnectedByUser( + url: string, + userId: string, + ): Promise { + if (!this.connectionRepository) { + return false; + } + + try { + const curatorId = CuratorId.create(userId); + if (curatorId.isErr()) { + return false; + } + + // Get all connections by this curator + const connectionsResult = await this.connectionRepository.findByCuratorId( + curatorId.value, + ); + if (connectionsResult.isErr()) { + return false; + } + + const connections = connectionsResult.value; + + // Check if any connection involves this URL (as source or target) + return connections.some( + (conn) => + (conn.source.isUrl && conn.source.url?.value === url) || + (conn.target.isUrl && conn.target.url?.value === url), + ); + } catch (error) { + return false; + } + } + async getCardsInCollection( collectionId: string, options: CardQueryOptions, @@ -265,9 +349,11 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { ); } - const collectionCardResults = collectionCards.map((card) => - this.toCollectionCardQueryResult( - this.cardToUrlCardQueryResult(card, callingUserId), + const collectionCardResults = await Promise.all( + collectionCards.map(async (card) => + this.toCollectionCardQueryResult( + await this.cardToUrlCardQueryResult(card, callingUserId), + ), ), ); @@ -352,7 +438,10 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { return null; } - const urlCardResult = this.cardToUrlCardQueryResult(card, callingUserId); + const urlCardResult = await this.cardToUrlCardQueryResult( + card, + callingUserId, + ); // Get library memberships from the card itself const libraries = card.libraryMemberships.map((membership) => ({ @@ -406,30 +495,35 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { } : undefined; + const url = card.content.urlContent!.url.value; + // Compute urlInLibrary if callingUserId is provided const urlInLibrary = callingUserId - ? this.isUrlInUserLibrary( - card.content.urlContent!.url.value, - callingUserId, - ) + ? this.isUrlInUserLibrary(url, callingUserId) + : undefined; + + // Get connection count and connection status + const urlConnectionCount = await this.getUrlConnectionCount(url); + const urlIsConnected = callingUserId + ? await this.isUrlConnectedByUser(url, callingUserId) : undefined; return { id: card.cardId.getStringValue(), type: CardTypeEnum.URL, - url: card.content.urlContent!.url.value, + url, cardContent: { - url: card.content.urlContent!.url.value, + url, title: card.content.urlContent!.metadata?.title, description: card.content.urlContent!.metadata?.description, author: card.content.urlContent!.metadata?.author, imageUrl: card.content.urlContent!.metadata?.imageUrl, }, libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), - urlLibraryCount: this.getUrlLibraryCount( - card.content.urlContent!.url.value, - ), + urlLibraryCount: this.getUrlLibraryCount(url), urlInLibrary, + urlConnectionCount, + urlIsConnected, createdAt: card.createdAt, updatedAt: card.updatedAt, authorId: card.curatorId.value, @@ -656,6 +750,12 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { ? this.isUrlInUserLibrary(url, callingUserId) : undefined; + // Get connection count and connection status + const urlConnectionCount = await this.getUrlConnectionCount(url); + const urlIsConnected = callingUserId + ? await this.isUrlConnectedByUser(url, callingUserId) + : undefined; + // Get metadata from any card with this URL const allCards = this.cardRepository.getAllCards(); const urlCard = allCards.find( @@ -683,6 +783,8 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { resultMap.set(url, { urlLibraryCount, urlInLibrary, + urlConnectionCount, + urlIsConnected, metadata, }); } diff --git a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts index a64318a0..be63b33f 100644 --- a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts @@ -129,19 +129,11 @@ export class GetFollowingFeedUseCase ), ]; - // Hydrate card data and fetch card authors - const cardDataMap = new Map(); - const cardViews = await Promise.all( - cardIds.map((cardId) => - this.cardQueryRepository.getUrlCardView(cardId, query.callingUserId), - ), + // Batch fetch card data + const cardDataMap = await this.cardQueryRepository.getBatchUrlCardViews( + cardIds, + query.callingUserId, ); - cardIds.forEach((cardId, idx) => { - const cardView = cardViews[idx]; - if (cardView) { - cardDataMap.set(cardId, cardView); - } - }); // Get unique card author IDs const cardAuthorIds = [ @@ -180,48 +172,50 @@ export class GetFollowingFeedUseCase ), ]; - // Fetch all collections first (without author data) - const collectionResults = await Promise.all( - collectionIds.map(async (collectionId) => { - const collectionIdResult = - CollectionId.createFromString(collectionId); - if (collectionIdResult.isErr()) { - return null; // Skip invalid collection IDs - } - const collectionResult = await this.collectionRepository.findById( - collectionIdResult.value, + // Batch fetch all collections + const collectionIdObjects: CollectionId[] = []; + const collectionIdMap = new Map(); // Map UUID string to original string + + for (const collectionId of collectionIds) { + const collectionIdResult = CollectionId.createFromString(collectionId); + if (collectionIdResult.isOk()) { + collectionIdObjects.push(collectionIdResult.value); + collectionIdMap.set( + collectionIdResult.value.getStringValue(), + collectionId, ); - if (collectionResult.isErr() || !collectionResult.value) { - return null; - } + } + } - const collection = collectionResult.value; - const uri = collection.publishedRecordId?.uri; + const collectionsResult = + await this.collectionRepository.findByIds(collectionIdObjects); - // Get the card IDs in this collection - const cardIds = new Set( - collection.cardIds.map((cardId) => cardId.getStringValue()), - ); + if (collectionsResult.isErr()) { + return err(AppError.UnexpectedError.create(collectionsResult.error)); + } - return { - id: collection.collectionId.getStringValue(), - uri, - name: collection.name.toString(), - description: collection.description?.toString(), - accessType: collection.accessType, - authorId: collection.authorId.value, - cardCount: collection.cardCount, - createdAt: collection.createdAt.toISOString(), - updatedAt: collection.updatedAt.toISOString(), - cardIds, - collectionId, - }; - }), - ); + const validCollections = collectionsResult.value.map((collection) => { + const uri = collection.publishedRecordId?.uri; + const cardIds = new Set( + collection.cardIds.map((cardId) => cardId.getStringValue()), + ); - const validCollections = collectionResults.filter( - (result) => result !== null, - ); + return { + id: collection.collectionId.getStringValue(), + uri, + name: collection.name.toString(), + description: collection.description?.toString(), + accessType: collection.accessType, + authorId: collection.authorId.value, + cardCount: collection.cardCount, + createdAt: collection.createdAt.toISOString(), + updatedAt: collection.updatedAt.toISOString(), + cardIds, + collectionId: collectionIdMap.get( + collection.collectionId.getStringValue(), + )!, + }; + }); // Get unique collection author IDs const collectionAuthorIds = [ @@ -340,6 +334,8 @@ export class GetFollowingFeedUseCase libraryCount: cardView.libraryCount, urlLibraryCount: cardView.urlLibraryCount, urlInLibrary: cardView.urlInLibrary, + urlConnectionCount: cardView.urlConnectionCount, + urlIsConnected: cardView.urlIsConnected, createdAt: cardView.createdAt.toISOString(), updatedAt: cardView.updatedAt.toISOString(), author: cardAuthor, diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index 0a049078..bf5a50da 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -349,6 +349,8 @@ export class GetGlobalFeedUseCase libraryCount: cardView.libraryCount, urlLibraryCount: cardView.urlLibraryCount, urlInLibrary: cardView.urlInLibrary, + urlConnectionCount: cardView.urlConnectionCount, + urlIsConnected: cardView.urlIsConnected, createdAt: cardView.createdAt.toISOString(), updatedAt: cardView.updatedAt.toISOString(), author: cardAuthor, diff --git a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts index ff222f37..269f9afd 100644 --- a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts +++ b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts @@ -266,6 +266,8 @@ export class GetMyNotificationsUseCase libraryCount: notification.cardLibraryCount || 0, urlLibraryCount: notification.cardUrlLibraryCount || 0, urlInLibrary: notification.cardUrlInLibrary, + urlConnectionCount: notification.cardUrlConnectionCount, + urlIsConnected: notification.cardUrlIsConnected, createdAt: notification.cardCreatedAt?.toISOString() || '', updatedAt: notification.cardUpdatedAt?.toISOString() || '', author: { diff --git a/src/modules/notifications/domain/INotificationRepository.ts b/src/modules/notifications/domain/INotificationRepository.ts index bdb45694..25e5173f 100644 --- a/src/modules/notifications/domain/INotificationRepository.ts +++ b/src/modules/notifications/domain/INotificationRepository.ts @@ -44,6 +44,8 @@ export interface EnrichedNotificationResult { cardLibraryCount?: number; cardUrlLibraryCount?: number; cardUrlInLibrary?: boolean; + cardUrlConnectionCount?: number; + cardUrlIsConnected?: boolean; cardCreatedAt?: Date; cardUpdatedAt?: Date; cardNote?: { diff --git a/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts index 0573457e..d90515ef 100644 --- a/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts +++ b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, count, inArray } from 'drizzle-orm'; +import { eq, desc, and, count, inArray, or, sql } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { INotificationRepository, @@ -23,6 +23,7 @@ import { } from '../../../cards/infrastructure/repositories/schema/collection.sql'; import { publishedRecords } from '../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; import { libraryMemberships } from '../../../cards/infrastructure/repositories/schema/libraryMembership.sql'; +import { connections } from '../../../cards/infrastructure/repositories/schema/connection.sql'; import { CardTypeEnum } from '../../../cards/domain/value-objects/CardType'; import { countDistinct } from 'drizzle-orm'; @@ -481,6 +482,8 @@ export class DrizzleNotificationRepository implements INotificationRepository { let cardsResult: any[] = []; let urlLibraryCountMap = new Map(); let urlInLibrarySet = new Set(); + let urlConnectionCountMap = new Map(); + let urlIsConnectedSet = new Set(); let notesResult: any[] = []; let collectionsResult: any[] = []; @@ -555,6 +558,70 @@ export class DrizzleNotificationRepository implements INotificationRepository { urlInLibrarySet.add(row.url); } }); + + // Get connection counts for these URLs + const urlConnectionCountsQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + count: count(), + }) + .from(connections) + .where( + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ) + .groupBy(sql`url`); + + const urlConnectionCountsResult = await urlConnectionCountsQuery; + urlConnectionCountsResult.forEach((row) => { + if (row.url) { + urlConnectionCountMap.set(row.url, row.count); + } + }); + + // Check which URLs the calling user has connections with + const urlIsConnectedQuery = this.db + .select({ + url: sql`CASE + WHEN ${connections.sourceType} = 'URL' THEN ${connections.sourceValue} + WHEN ${connections.targetType} = 'URL' THEN ${connections.targetValue} + END`.as('url'), + }) + .from(connections) + .where( + and( + eq(connections.curatorId, callingUserId), + or( + and( + eq(connections.sourceType, 'URL'), + inArray(connections.sourceValue, urls), + ), + and( + eq(connections.targetType, 'URL'), + inArray(connections.targetValue, urls), + ), + ), + ), + ) + .groupBy(sql`url`); + + const urlIsConnectedResult = await urlIsConnectedQuery; + urlIsConnectedResult.forEach((row) => { + if (row.url) { + urlIsConnectedSet.add(row.url); + } + }); } // Get notes for these cards @@ -724,6 +791,11 @@ export class DrizzleNotificationRepository implements INotificationRepository { const urlLibraryCount = urlLibraryCountMap.get(card.url || '') || 0; const urlInLibrary = card.url ? urlInLibrarySet.has(card.url) : false; + const urlConnectionCount = + urlConnectionCountMap.get(card.url || '') || 0; + const urlIsConnected = card.url + ? urlIsConnectedSet.has(card.url) + : false; enrichedNotifications.push({ id: notification.id, @@ -752,6 +824,8 @@ export class DrizzleNotificationRepository implements INotificationRepository { cardLibraryCount: card.libraryCount, cardUrlLibraryCount: urlLibraryCount, cardUrlInLibrary: urlInLibrary, + cardUrlConnectionCount: urlConnectionCount, + cardUrlIsConnected: urlIsConnected, cardCreatedAt: card.createdAt, cardUpdatedAt: card.updatedAt, cardNote: note diff --git a/src/modules/search/domain/services/SearchService.ts b/src/modules/search/domain/services/SearchService.ts index 428af865..ab1d02fa 100644 --- a/src/modules/search/domain/services/SearchService.ts +++ b/src/modules/search/domain/services/SearchService.ts @@ -303,47 +303,40 @@ export class SearchService { }>, callingUserId?: string, ): Promise { + // Extract all URLs + const urls = searchResults.map((result) => result.url); + + // Batch fetch library info for all URLs + const urlLibraryInfoMap = + await this.cardQueryRepository.getBatchUrlLibraryInfo( + urls, + callingUserId, + ); + // Enrich each URL with library context - const enrichedResults = await Promise.all( - searchResults.map(async (result) => { - // Get library information for this URL - const librariesResult = - await this.cardQueryRepository.getLibrariesForUrl(result.url, { - page: 1, - limit: 1000, // Get all libraries to count them - sortBy: CardSortField.CREATED_AT, - sortOrder: SortOrder.DESC, - }); - - const urlLibraryCount = librariesResult.totalCount; - - // Check if calling user has this URL in their library - // Default to false if no calling user (unauthenticated request) - const urlInLibrary = callingUserId - ? librariesResult.items.some( - (library) => library.userId === callingUserId, - ) - : false; + const enrichedResults = searchResults.map((result) => { + const libraryInfo = urlLibraryInfoMap.get(result.url); - return { + return { + url: result.url, + metadata: { url: result.url, - metadata: { - url: result.url, - title: result.metadata.title, - description: result.metadata.description, - author: result.metadata.author, - siteName: result.metadata.siteName, - imageUrl: result.metadata.imageUrl, - type: result.metadata.type, - retrievedAt: result.metadata.retrievedAt?.toISOString(), - doi: result.metadata.doi, - isbn: result.metadata.isbn, - }, - urlLibraryCount, - urlInLibrary, - }; - }), - ); + title: result.metadata.title, + description: result.metadata.description, + author: result.metadata.author, + siteName: result.metadata.siteName, + imageUrl: result.metadata.imageUrl, + type: result.metadata.type, + retrievedAt: result.metadata.retrievedAt?.toISOString(), + doi: result.metadata.doi, + isbn: result.metadata.isbn, + }, + urlLibraryCount: libraryInfo?.urlLibraryCount || 0, + urlInLibrary: libraryInfo?.urlInLibrary, + urlConnectionCount: libraryInfo?.urlConnectionCount, + urlIsConnected: libraryInfo?.urlIsConnected, + }; + }); return enrichedResults; } @@ -357,37 +350,40 @@ export class SearchService { callingUserId?: string, filterByUserId?: string, ): Promise { + // Extract all URLs + const urls = searchResults.map((result) => result.url); + + // Batch fetch library info for all URLs + const urlLibraryInfoMap = + await this.cardQueryRepository.getBatchUrlLibraryInfo( + urls, + callingUserId, + ); + + // If filtering by user, get all their URLs once + let userUrlSet: Set | undefined; + if (filterByUserId) { + const userCardsResult = await this.cardQueryRepository.getUrlCardsOfUser( + filterByUserId, + { + page: 1, + limit: 10000, // Large limit to get all URLs + sortBy: CardSortField.CREATED_AT, + sortOrder: SortOrder.DESC, + }, + ); + userUrlSet = new Set(userCardsResult.items.map((card) => card.url)); + } + // Enrich each URL with library context and filter by user - const enrichedResults = await Promise.all( - searchResults.map(async (result) => { - // Get library information for this URL - const librariesResult = - await this.cardQueryRepository.getLibrariesForUrl(result.url, { - page: 1, - limit: 1000, // Get all libraries to count them - sortBy: CardSortField.CREATED_AT, - sortOrder: SortOrder.DESC, - }); - - // If filtering by user, check if this user has the URL - if (filterByUserId) { - const userHasUrl = librariesResult.items.some( - (library) => library.userId === filterByUserId, - ); - if (!userHasUrl) { - return null; // Filter out this result - } + const enrichedResults = searchResults + .map((result) => { + // Filter by user if needed + if (filterByUserId && userUrlSet && !userUrlSet.has(result.url)) { + return null; // Filter out this result } - const urlLibraryCount = librariesResult.totalCount; - - // Check if calling user has this URL in their library - // Default to false if no calling user (unauthenticated request) - const urlInLibrary = callingUserId - ? librariesResult.items.some( - (library) => library.userId === callingUserId, - ) - : false; + const libraryInfo = urlLibraryInfoMap.get(result.url); return { url: result.url, @@ -403,13 +399,14 @@ export class SearchService { doi: result.metadata.doi, isbn: result.metadata.isbn, }, - urlLibraryCount, - urlInLibrary, + urlLibraryCount: libraryInfo?.urlLibraryCount || 0, + urlInLibrary: libraryInfo?.urlInLibrary, + urlConnectionCount: libraryInfo?.urlConnectionCount, + urlIsConnected: libraryInfo?.urlIsConnected, }; - }), - ); + }) + .filter((result) => result !== null) as UrlView[]; - // Filter out null results - return enrichedResults.filter((result) => result !== null); + return enrichedResults; } } diff --git a/src/shared/infrastructure/http/factories/RepositoryFactory.ts b/src/shared/infrastructure/http/factories/RepositoryFactory.ts index 2e516fc1..12fac2fe 100644 --- a/src/shared/infrastructure/http/factories/RepositoryFactory.ts +++ b/src/shared/infrastructure/http/factories/RepositoryFactory.ts @@ -89,6 +89,7 @@ export class RepositoryFactory { const cardQueryRepository = new InMemoryCardQueryRepository( cardRepository, collectionRepository, + connectionRepository, ); const collectionQueryRepository = new InMemoryCollectionQueryRepository( collectionRepository, diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index b33f7948..83d0adde 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -103,6 +103,8 @@ export interface UrlCard { libraryCount: number; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; createdAt: string; updatedAt: string; author: User; @@ -246,6 +248,8 @@ export interface UrlView { metadata: UrlMetadata; urlLibraryCount: number; urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; } export interface GetSimilarUrlsForUrlResponse { -- 2.51.2