From 23de5b34813fc57770ae50d692e07c67d0fa750d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 12 Mar 2026 19:28:47 -0700 Subject: [PATCH] consolidate connection fetching for url and include both source and target --- .../GetBackwardConnectionsForUrlUseCase.ts | 271 ------------------ ...Case.ts => GetConnectionsForUrlUseCase.ts} | 120 ++++---- .../domain/IConnectionQueryRepository.ts | 13 + ...r.ts => GetConnectionsForUrlController.ts} | 21 +- .../GetForwardConnectionsForUrlController.ts | 65 ----- .../http/routes/connectionRoutes.ts | 17 +- .../DrizzleConnectionQueryRepository.ts | 12 + .../query-services/ConnectionQueryService.ts | 106 +++++++ .../InMemoryConnectionQueryRepository.ts | 89 +++++- src/shared/infrastructure/http/app.ts | 3 +- .../http/factories/ControllerFactory.ts | 17 +- .../http/factories/UseCaseFactory.ts | 26 +- src/types/src/api/requests.ts | 6 + src/types/src/api/responses.ts | 7 + src/webapp/api-client/ApiClient.ts | 8 + src/webapp/api-client/clients/QueryClient.ts | 22 ++ 16 files changed, 367 insertions(+), 436 deletions(-) delete mode 100644 src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts rename src/modules/cards/application/useCases/queries/{GetForwardConnectionsForUrlUseCase.ts => GetConnectionsForUrlUseCase.ts} (70%) rename src/modules/cards/infrastructure/http/controllers/{GetBackwardConnectionsForUrlController.ts => GetConnectionsForUrlController.ts} (71%) delete mode 100644 src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts diff --git a/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts deleted file mode 100644 index f6d7a722..00000000 --- a/src/modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { Result, ok, err } from '../../../../../shared/core/Result'; -import { UseCase } from '../../../../../shared/core/UseCase'; -import { - IConnectionQueryRepository, - ConnectionSortField, - SortOrder, -} from '../../../domain/IConnectionQueryRepository'; -import { ICardQueryRepository } from '../../../domain/ICardQueryRepository'; -import { URL } from '../../../domain/value-objects/URL'; -import { IProfileService } from '../../../domain/services/IProfileService'; -import { UserProfileDTO, UrlMetadata, PaginationDTO } from '@semble/types'; -import { ProfileEnricher } from '../../services/ProfileEnricher'; -import { ConnectionTypeEnum } from '../../../domain/value-objects/ConnectionType'; -import { IMetadataService } from '../../../domain/services/IMetadataService'; -import { UrlMetadata as UrlMetadataVO } from '../../../domain/value-objects/UrlMetadata'; - -export interface GetBackwardConnectionsForUrlQuery { - url: string; - callingUserId?: string; - page?: number; - limit?: number; - sortBy?: ConnectionSortField; - sortOrder?: SortOrder; - connectionTypes?: ConnectionTypeEnum[]; -} - -export interface ConnectionForUrlView { - connection: { - id: string; - type?: string; - note?: string; - createdAt: string; - updatedAt: string; - curator: UserProfileDTO; - }; - url: { - url: string; - metadata: UrlMetadata; - urlLibraryCount: number; - urlInLibrary?: boolean; - urlConnectionCount?: number; - urlIsConnected?: boolean; - }; -} - -export interface GetBackwardConnectionsForUrlResult { - connections: ConnectionForUrlView[]; - pagination: PaginationDTO; - sorting: { - sortBy: ConnectionSortField; - sortOrder: SortOrder; - }; -} - -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } -} - -export class GetBackwardConnectionsForUrlUseCase - implements - UseCase< - GetBackwardConnectionsForUrlQuery, - Result - > -{ - constructor( - private connectionQueryRepo: IConnectionQueryRepository, - private cardQueryRepo: ICardQueryRepository, - private profileService: IProfileService, - private metadataService: IMetadataService, - ) {} - - async execute( - query: GetBackwardConnectionsForUrlQuery, - ): Promise> { - // Validate URL - const urlResult = URL.create(query.url); - if (urlResult.isErr()) { - return err( - new ValidationError(`Invalid URL: ${urlResult.error.message}`), - ); - } - - // Set defaults - const page = query.page || 1; - const limit = Math.min(query.limit || 20, 100); // Cap at 100 - const sortBy = query.sortBy || ConnectionSortField.CREATED_AT; - const sortOrder = query.sortOrder || SortOrder.DESC; - const connectionTypes = query.connectionTypes; // Optional filter - - try { - // Execute query to get backward connections - const result = - await this.connectionQueryRepo.getBackwardConnectionsForUrl( - urlResult.value.value, - { - page, - limit, - sortBy, - sortOrder, - connectionTypes, - }, - ); - - // Extract unique curator IDs and URLs - const uniqueCuratorIds = Array.from( - new Set(result.items.map((item) => item.connection.curatorId)), - ); - const uniqueUrls = Array.from( - new Set(result.items.map((item) => item.url)), - ); - - // Fetch curator profiles using ProfileEnricher - const profileEnricher = new ProfileEnricher(this.profileService); - const profileMapResult = await profileEnricher.buildProfileMap( - uniqueCuratorIds, - query.callingUserId, - { - skipFailures: false, - mapToUser: false, - }, - ); - - if (profileMapResult.isErr()) { - return err( - new Error( - `Failed to fetch curator profiles: ${profileMapResult.error.message}`, - ), - ); - } - - const profileMap = profileMapResult.value; - - // Fetch metadata from external service in parallel - const metadataResults = await Promise.allSettled( - uniqueUrls.map(async (urlString) => { - const urlResult = URL.create(urlString); - if (urlResult.isErr()) { - return { url: urlString, metadata: null }; - } - const metadataResult = await this.metadataService.fetchMetadata( - urlResult.value, - ); - if (metadataResult.isOk()) { - return { url: urlString, metadata: metadataResult.value }; - } - // Fallback to minimal metadata if fetch fails - const fallbackResult = UrlMetadataVO.create({ url: urlString }); - return { - url: urlString, - metadata: fallbackResult.isOk() ? fallbackResult.value : null, - }; - }), - ); - - // Build metadata map from external fetch results - const externalMetadataMap = new Map(); - metadataResults.forEach((result) => { - if (result.status === 'fulfilled') { - externalMetadataMap.set(result.value.url, result.value.metadata); - } - }); - - // Fetch URL library info for counts and in-library status - const urlLibraryInfoMap = await this.cardQueryRepo.getBatchUrlLibraryInfo( - uniqueUrls, - query.callingUserId, - ); - - // Convert to the format expected by the rest of the code - const urlDataMap = new Map< - string, - { - metadata: UrlMetadata; - urlLibraryCount: number; - urlInLibrary?: boolean; - urlConnectionCount?: number; - urlIsConnected?: boolean; - } - >(); - - uniqueUrls.forEach((url) => { - const urlInfo = urlLibraryInfoMap.get(url); - const externalMetadata = externalMetadataMap.get(url); - - if (urlInfo && externalMetadata) { - // Convert UrlMetadataVO to UrlMetadata DTO with dates as ISO strings - const metadata: UrlMetadata = { - url: externalMetadata.url, - title: externalMetadata.title, - description: externalMetadata.description, - author: externalMetadata.author, - siteName: externalMetadata.siteName, - imageUrl: externalMetadata.imageUrl, - type: externalMetadata.type, - doi: externalMetadata.doi, - isbn: externalMetadata.isbn, - }; - - urlDataMap.set(url, { - metadata, - urlLibraryCount: urlInfo.urlLibraryCount, - urlInLibrary: urlInfo.urlInLibrary, - urlConnectionCount: urlInfo.urlConnectionCount, - urlIsConnected: urlInfo.urlIsConnected, - }); - } - }); - - // Map items with enriched data - const enrichedConnections: ConnectionForUrlView[] = result.items.map( - (item) => { - const curator = profileMap.get(item.connection.curatorId); - if (!curator) { - throw new Error( - `Profile not found for curator ${item.connection.curatorId}`, - ); - } - - const urlData = urlDataMap.get(item.url); - if (!urlData) { - throw new Error(`URL data not found for ${item.url}`); - } - - return { - connection: { - id: item.connection.id, - type: item.connection.type, - note: item.connection.note, - createdAt: item.connection.createdAt.toISOString(), - updatedAt: item.connection.updatedAt.toISOString(), - curator, - }, - url: { - url: item.url, - metadata: urlData.metadata, - urlLibraryCount: urlData.urlLibraryCount, - urlInLibrary: urlData.urlInLibrary, - urlConnectionCount: urlData.urlConnectionCount, - urlIsConnected: urlData.urlIsConnected, - }, - }; - }, - ); - - return ok({ - connections: enrichedConnections, - pagination: { - currentPage: page, - totalPages: Math.ceil(result.totalCount / limit), - totalCount: result.totalCount, - hasMore: page * limit < result.totalCount, - limit, - }, - sorting: { - sortBy, - sortOrder, - }, - }); - } catch (error) { - return err( - new Error( - `Failed to retrieve backward connections for URL: ${error instanceof Error ? error.message : 'Unknown error'}`, - ), - ); - } - } -} diff --git a/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetConnectionsForUrlUseCase.ts similarity index 70% rename from src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts rename to src/modules/cards/application/useCases/queries/GetConnectionsForUrlUseCase.ts index b98c6825..d86bf5a0 100644 --- a/src/modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetConnectionsForUrlUseCase.ts @@ -14,8 +14,9 @@ import { ConnectionTypeEnum } from '../../../domain/value-objects/ConnectionType import { IMetadataService } from '../../../domain/services/IMetadataService'; import { UrlMetadata as UrlMetadataVO } from '../../../domain/value-objects/UrlMetadata'; -export interface GetForwardConnectionsForUrlQuery { +export interface GetConnectionsForUrlQuery { url: string; + direction?: 'forward' | 'backward' | 'both'; callingUserId?: string; page?: number; limit?: number; @@ -24,7 +25,7 @@ export interface GetForwardConnectionsForUrlQuery { connectionTypes?: ConnectionTypeEnum[]; } -export interface ConnectionForUrlView { +export interface ConnectionView { connection: { id: string; type?: string; @@ -33,7 +34,15 @@ export interface ConnectionForUrlView { updatedAt: string; curator: UserProfileDTO; }; - url: { + source: { + url: string; + metadata: UrlMetadata; + urlLibraryCount: number; + urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; + }; + target: { url: string; metadata: UrlMetadata; urlLibraryCount: number; @@ -43,8 +52,8 @@ export interface ConnectionForUrlView { }; } -export interface GetForwardConnectionsForUrlResult { - connections: ConnectionForUrlView[]; +export interface GetConnectionsForUrlResult { + connections: ConnectionView[]; pagination: PaginationDTO; sorting: { sortBy: ConnectionSortField; @@ -59,12 +68,9 @@ export class ValidationError extends Error { } } -export class GetForwardConnectionsForUrlUseCase +export class GetConnectionsForUrlUseCase implements - UseCase< - GetForwardConnectionsForUrlQuery, - Result - > + UseCase> { constructor( private connectionQueryRepo: IConnectionQueryRepository, @@ -74,8 +80,8 @@ export class GetForwardConnectionsForUrlUseCase ) {} async execute( - query: GetForwardConnectionsForUrlQuery, - ): Promise> { + query: GetConnectionsForUrlQuery, + ): Promise> { // Validate URL const urlResult = URL.create(query.url); if (urlResult.isErr()) { @@ -89,12 +95,14 @@ export class GetForwardConnectionsForUrlUseCase const limit = Math.min(query.limit || 20, 100); // Cap at 100 const sortBy = query.sortBy || ConnectionSortField.CREATED_AT; const sortOrder = query.sortOrder || SortOrder.DESC; + const direction = query.direction || 'both'; const connectionTypes = query.connectionTypes; // Optional filter try { - // Execute query to get forward connections - const result = await this.connectionQueryRepo.getForwardConnectionsForUrl( + // Execute query to get connections + const result = await this.connectionQueryRepo.getConnectionsForUrl( urlResult.value.value, + direction, { page, limit, @@ -104,12 +112,15 @@ export class GetForwardConnectionsForUrlUseCase }, ); - // Extract unique curator IDs and URLs + // Extract unique curator IDs and URLs from both source and target const uniqueCuratorIds = Array.from( new Set(result.items.map((item) => item.connection.curatorId)), ); const uniqueUrls = Array.from( - new Set(result.items.map((item) => item.url)), + new Set([ + ...result.items.map((item) => item.sourceUrl), + ...result.items.map((item) => item.targetUrl), + ]), ); // Fetch curator profiles using ProfileEnricher @@ -210,40 +221,51 @@ export class GetForwardConnectionsForUrlUseCase }); // Map items with enriched data - const enrichedConnections: ConnectionForUrlView[] = result.items.map( - (item) => { - const curator = profileMap.get(item.connection.curatorId); - if (!curator) { - throw new Error( - `Profile not found for curator ${item.connection.curatorId}`, - ); - } + const enrichedConnections: ConnectionView[] = result.items.map((item) => { + const curator = profileMap.get(item.connection.curatorId); + if (!curator) { + throw new Error( + `Profile not found for curator ${item.connection.curatorId}`, + ); + } - const urlData = urlDataMap.get(item.url); - if (!urlData) { - throw new Error(`URL data not found for ${item.url}`); - } + const sourceData = urlDataMap.get(item.sourceUrl); + if (!sourceData) { + throw new Error(`URL data not found for ${item.sourceUrl}`); + } - return { - connection: { - id: item.connection.id, - type: item.connection.type, - note: item.connection.note, - createdAt: item.connection.createdAt.toISOString(), - updatedAt: item.connection.updatedAt.toISOString(), - curator, - }, - url: { - url: item.url, - metadata: urlData.metadata, - urlLibraryCount: urlData.urlLibraryCount, - urlInLibrary: urlData.urlInLibrary, - urlConnectionCount: urlData.urlConnectionCount, - urlIsConnected: urlData.urlIsConnected, - }, - }; - }, - ); + const targetData = urlDataMap.get(item.targetUrl); + if (!targetData) { + throw new Error(`URL data not found for ${item.targetUrl}`); + } + + return { + connection: { + id: item.connection.id, + type: item.connection.type, + note: item.connection.note, + createdAt: item.connection.createdAt.toISOString(), + updatedAt: item.connection.updatedAt.toISOString(), + curator, + }, + source: { + url: item.sourceUrl, + 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, + }, + }; + }); return ok({ connections: enrichedConnections, @@ -262,7 +284,7 @@ export class GetForwardConnectionsForUrlUseCase } catch (error) { return err( new Error( - `Failed to retrieve forward connections for URL: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Failed to retrieve connections for URL: ${error instanceof Error ? error.message : 'Unknown error'}`, ), ); } diff --git a/src/modules/cards/domain/IConnectionQueryRepository.ts b/src/modules/cards/domain/IConnectionQueryRepository.ts index a22906e8..c25ad36f 100644 --- a/src/modules/cards/domain/IConnectionQueryRepository.ts +++ b/src/modules/cards/domain/IConnectionQueryRepository.ts @@ -70,6 +70,19 @@ export interface IConnectionQueryRepository { options: ConnectionQueryOptions, ): Promise>; + /** + * Get connections for a URL with optional direction filtering + * Returns connections with both source and target URLs + * @param url - The URL to get connections for + * @param direction - 'forward' (source), 'backward' (target), or 'both' (either) + * @param options - Pagination, sorting, and filtering options + */ + getConnectionsForUrl( + url: string, + direction: 'forward' | 'backward' | 'both', + options: ConnectionQueryOptions, + ): Promise>; + /** * Get all connections created by a specific curator * Returns connections with both source and target URLs diff --git a/src/modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController.ts b/src/modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController.ts similarity index 71% rename from src/modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController.ts rename to src/modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController.ts index 0faca1a9..e71460ab 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController.ts @@ -1,6 +1,6 @@ import { Controller } from '../../../../../shared/infrastructure/http/Controller'; import { Response } from 'express'; -import { GetBackwardConnectionsForUrlUseCase } from '../../../application/useCases/queries/GetBackwardConnectionsForUrlUseCase'; +import { GetConnectionsForUrlUseCase } from '../../../application/useCases/queries/GetConnectionsForUrlUseCase'; import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; import { ConnectionSortField, @@ -8,9 +8,9 @@ import { } from '../../../domain/IConnectionQueryRepository'; import { ConnectionTypeEnum } from '../../../domain/value-objects/ConnectionType'; -export class GetBackwardConnectionsForUrlController extends Controller { +export class GetConnectionsForUrlController extends Controller { constructor( - private getBackwardConnectionsForUrlUseCase: GetBackwardConnectionsForUrlUseCase, + private getConnectionsForUrlUseCase: GetConnectionsForUrlUseCase, ) { super(); } @@ -24,6 +24,18 @@ export class GetBackwardConnectionsForUrlController extends Controller { return this.badRequest(res, 'URL is required'); } + // Parse direction parameter + const direction = + (req.query.direction as 'forward' | 'backward' | 'both') || 'both'; + + // Validate direction + if (!['forward', 'backward', 'both'].includes(direction)) { + return this.badRequest( + res, + 'Invalid direction. Must be "forward", "backward", or "both"', + ); + } + // Parse pagination parameters const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; const limit = req.query.limit @@ -43,8 +55,9 @@ export class GetBackwardConnectionsForUrlController extends Controller { connectionTypes = typesParam.split(',') as ConnectionTypeEnum[]; } - const result = await this.getBackwardConnectionsForUrlUseCase.execute({ + const result = await this.getConnectionsForUrlUseCase.execute({ url, + direction, callingUserId, page, limit, diff --git a/src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts b/src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts deleted file mode 100644 index 61d0fff7..00000000 --- a/src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Controller } from '../../../../../shared/infrastructure/http/Controller'; -import { Response } from 'express'; -import { GetForwardConnectionsForUrlUseCase } from '../../../application/useCases/queries/GetForwardConnectionsForUrlUseCase'; -import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; -import { - ConnectionSortField, - SortOrder, -} from '../../../domain/IConnectionQueryRepository'; -import { ConnectionTypeEnum } from '../../../domain/value-objects/ConnectionType'; - -export class GetForwardConnectionsForUrlController extends Controller { - constructor( - private getForwardConnectionsForUrlUseCase: GetForwardConnectionsForUrlUseCase, - ) { - super(); - } - - async executeImpl(req: AuthenticatedRequest, res: Response): Promise { - try { - const { url } = req.query; - const callingUserId = req.did; - - if (!url || typeof url !== 'string') { - return this.badRequest(res, 'URL is required'); - } - - // Parse pagination parameters - const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; - const limit = req.query.limit - ? parseInt(req.query.limit as string, 10) - : 20; - - // Parse sorting parameters - const sortBy = - (req.query.sortBy as ConnectionSortField) || - ConnectionSortField.CREATED_AT; - const sortOrder = (req.query.sortOrder as SortOrder) || SortOrder.DESC; - - // Parse connection types filter - let connectionTypes: ConnectionTypeEnum[] | undefined; - if (req.query.connectionTypes) { - const typesParam = req.query.connectionTypes as string; - connectionTypes = typesParam.split(',') as ConnectionTypeEnum[]; - } - - const result = await this.getForwardConnectionsForUrlUseCase.execute({ - url, - callingUserId, - page, - limit, - sortBy, - sortOrder, - connectionTypes, - }); - - if (result.isErr()) { - return this.fail(res, result.error); - } - - return this.ok(res, result.value); - } catch (error: any) { - return this.handleError(res, error); - } - } -} diff --git a/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts b/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts index 412c551e..e1f5295e 100644 --- a/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts @@ -3,8 +3,7 @@ import { CreateConnectionController } from '../controllers/CreateConnectionContr import { UpdateConnectionController } from '../controllers/UpdateConnectionController'; import { DeleteConnectionController } from '../controllers/DeleteConnectionController'; import { GetConnectionsController } from '../controllers/GetConnectionsController'; -import { GetForwardConnectionsForUrlController } from '../controllers/GetForwardConnectionsForUrlController'; -import { GetBackwardConnectionsForUrlController } from '../controllers/GetBackwardConnectionsForUrlController'; +import { GetConnectionsForUrlController } from '../controllers/GetConnectionsForUrlController'; import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; export function createConnectionRoutes( @@ -13,8 +12,7 @@ export function createConnectionRoutes( updateConnectionController: UpdateConnectionController, deleteConnectionController: DeleteConnectionController, getConnectionsController: GetConnectionsController, - getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController, - getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController, + getConnectionsForUrlController: GetConnectionsForUrlController, ): Router { const router = Router(); @@ -24,14 +22,9 @@ export function createConnectionRoutes( getConnectionsController.execute(req, res), ); - // GET /api/connections/forward - Get forward connections for URL - router.get('/forward', authMiddleware.optionalAuth(), (req, res) => - getForwardConnectionsForUrlController.execute(req, res), - ); - - // GET /api/connections/backward - Get backward connections for URL - router.get('/backward', authMiddleware.optionalAuth(), (req, res) => - getBackwardConnectionsForUrlController.execute(req, res), + // GET /api/connections/url - Get connections for a URL with optional direction filtering + router.get('/url', authMiddleware.optionalAuth(), (req, res) => + getConnectionsForUrlController.execute(req, res), ); // Command routes diff --git a/src/modules/cards/infrastructure/repositories/DrizzleConnectionQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleConnectionQueryRepository.ts index aa9038a6..278b29e7 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleConnectionQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleConnectionQueryRepository.ts @@ -38,6 +38,18 @@ export class DrizzleConnectionQueryRepository ); } + async getConnectionsForUrl( + url: string, + direction: 'forward' | 'backward' | 'both', + options: ConnectionQueryOptions, + ): Promise> { + return this.connectionQueryService.getConnectionsForUrlWithDirection( + url, + direction, + options, + ); + } + async getConnectionsForUser( curatorId: string, options: ConnectionQueryOptions, diff --git a/src/modules/cards/infrastructure/repositories/query-services/ConnectionQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/ConnectionQueryService.ts index 7fd53ed8..2a98239e 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/ConnectionQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/ConnectionQueryService.ts @@ -29,6 +29,112 @@ export class ConnectionQueryService { return this.getConnectionsForUrl(url, 'target', options); } + async getConnectionsForUrlWithDirection( + url: string, + direction: 'forward' | 'backward' | 'both', + options: ConnectionQueryOptions, + ): Promise> { + const { page, limit, sortBy, sortOrder, connectionTypes } = options; + const offset = (page - 1) * limit; + + // Build WHERE conditions based on direction + let whereConditions: SQL[] = []; + + if (direction === 'forward') { + // URL is the source + whereConditions.push( + and( + eq(connections.sourceType, 'URL'), + eq(connections.sourceValue, url), + eq(connections.targetType, 'URL'), + )!, + ); + } else if (direction === 'backward') { + // URL is the target + whereConditions.push( + and( + eq(connections.targetType, 'URL'), + eq(connections.targetValue, url), + eq(connections.sourceType, 'URL'), + )!, + ); + } else { + // both - URL is either source or target + whereConditions.push( + sql`( + (${connections.sourceType} = 'URL' AND ${connections.sourceValue} = ${url} AND ${connections.targetType} = 'URL') + OR + (${connections.targetType} = 'URL' AND ${connections.targetValue} = ${url} AND ${connections.sourceType} = 'URL') + )`, + ); + } + + // Add connection type filter if provided + if (connectionTypes && connectionTypes.length > 0) { + whereConditions.push( + inArray(connections.connectionType, connectionTypes), + ); + } + + // Combine WHERE conditions + const whereClause = + whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]; + + // Determine sort column and order + const sortColumn = + sortBy === ConnectionSortField.UPDATED_AT + ? connections.updatedAt + : connections.createdAt; + + const sortOrderFn = sortOrder === SortOrder.ASC ? asc : desc; + + // Execute main query with pagination + const results = await this.db + .select({ + id: connections.id, + curatorId: connections.curatorId, + sourceValue: connections.sourceValue, + targetValue: connections.targetValue, + connectionType: connections.connectionType, + note: connections.note, + createdAt: connections.createdAt, + updatedAt: connections.updatedAt, + }) + .from(connections) + .where(whereClause) + .orderBy(sortOrderFn(sortColumn)) + .limit(limit) + .offset(offset); + + // Execute count query for total count + const countResult = await this.db + .select({ count: count() }) + .from(connections) + .where(whereClause); + + const totalCount = countResult[0]?.count || 0; + + // Map results to DTOs + const items: ConnectionForUserDTO[] = results.map((row) => ({ + connection: { + id: row.id, + type: row.connectionType as ConnectionTypeEnum | undefined, + note: row.note || undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + curatorId: row.curatorId, + }, + sourceUrl: row.sourceValue, + targetUrl: row.targetValue, + })); + + return { + items, + totalCount, + hasMore: offset + results.length < totalCount, + }; + } + private async getConnectionsForUrl( url: string, direction: 'source' | 'target', diff --git a/src/modules/cards/tests/utils/InMemoryConnectionQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryConnectionQueryRepository.ts index a06c2b28..88197237 100644 --- a/src/modules/cards/tests/utils/InMemoryConnectionQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryConnectionQueryRepository.ts @@ -20,17 +20,100 @@ export class InMemoryConnectionQueryRepository url: string, options: ConnectionQueryOptions, ): Promise> { - return this.getConnectionsForUrl(url, 'source', options); + return this.getConnectionsForUrlPrivate(url, 'source', options); } async getBackwardConnectionsForUrl( url: string, options: ConnectionQueryOptions, ): Promise> { - return this.getConnectionsForUrl(url, 'target', options); + return this.getConnectionsForUrlPrivate(url, 'target', options); } - private async getConnectionsForUrl( + async getConnectionsForUrl( + url: string, + direction: 'forward' | 'backward' | 'both', + options: ConnectionQueryOptions, + ): Promise> { + const { page, limit, sortBy, sortOrder, connectionTypes } = options; + + // Get all connections from the repository + const allConnections = this.connectionRepository.getAllConnections(); + + // Filter connections based on direction and URL type + let filteredConnections = allConnections.filter((connection) => { + const isSourceMatch = + connection.source.type === UrlOrCardIdType.URL && + connection.source.stringValue === url; + const isTargetMatch = + connection.target.type === UrlOrCardIdType.URL && + connection.target.stringValue === url; + const bothUrlToUrl = + connection.source.type === UrlOrCardIdType.URL && + connection.target.type === UrlOrCardIdType.URL; + + if (direction === 'forward') { + return isSourceMatch && bothUrlToUrl; + } else if (direction === 'backward') { + return isTargetMatch && bothUrlToUrl; + } else { + // both + return (isSourceMatch || isTargetMatch) && bothUrlToUrl; + } + }); + + // Apply connection type filter if provided + if (connectionTypes && connectionTypes.length > 0) { + filteredConnections = filteredConnections.filter((connection) => + connection.type + ? connectionTypes.includes(connection.type.value) + : false, + ); + } + + // Sort connections + filteredConnections.sort((a, b) => { + const dateA = + sortBy === ConnectionSortField.UPDATED_AT ? a.updatedAt : a.createdAt; + const dateB = + sortBy === ConnectionSortField.UPDATED_AT ? b.updatedAt : b.createdAt; + + const comparison = dateA.getTime() - dateB.getTime(); + return sortOrder === SortOrder.ASC ? comparison : -comparison; + }); + + // Apply pagination + const offset = (page - 1) * limit; + const paginatedConnections = filteredConnections.slice( + offset, + offset + limit, + ); + + // Map to DTOs with both source and target + const items: ConnectionForUserDTO[] = paginatedConnections.map( + (connection) => ({ + connection: { + id: connection.connectionId.getStringValue(), + type: connection.type?.value, + note: connection.note?.value, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + curatorId: connection.curatorId.value, + }, + sourceUrl: connection.source.stringValue, + targetUrl: connection.target.stringValue, + }), + ); + + return { + items, + totalCount: filteredConnections.length, + hasMore: + offset + paginatedConnections.length < filteredConnections.length, + }; + } + + private async getConnectionsForUrlPrivate( url: string, direction: 'source' | 'target', options: ConnectionQueryOptions, diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 154aecb8..f67ec4f6 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -139,8 +139,7 @@ export const createExpressApp = ( controllers.updateConnectionController, controllers.deleteConnectionController, controllers.getConnectionsController, - controllers.getForwardConnectionsForUrlController, - controllers.getBackwardConnectionsForUrlController, + controllers.getConnectionsForUrlController, ); const graphRouter = createGraphRoutes( diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index 6a48678c..4b53c2d5 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -59,8 +59,7 @@ import { CreateConnectionController } from '../../../../modules/cards/infrastruc import { UpdateConnectionController } from '../../../../modules/cards/infrastructure/http/controllers/UpdateConnectionController'; import { DeleteConnectionController } from '../../../../modules/cards/infrastructure/http/controllers/DeleteConnectionController'; import { GetConnectionsController } from '../../../../modules/cards/infrastructure/http/controllers/GetConnectionsController'; -import { GetForwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController'; -import { GetBackwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController'; +import { GetConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetConnectionsForUrlController'; import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController'; import { GetGraphDataController } from '../../../../modules/cards/infrastructure/http/controllers/GetGraphDataController'; import { CookieService } from '../services/CookieService'; @@ -117,8 +116,7 @@ export interface Controllers { createConnectionController: CreateConnectionController; updateConnectionController: UpdateConnectionController; deleteConnectionController: DeleteConnectionController; - getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController; - getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController; + getConnectionsForUrlController: GetConnectionsForUrlController; // Graph controllers getGraphDataController: GetGraphDataController; // Search controllers @@ -304,14 +302,9 @@ export class ControllerFactory { deleteConnectionController: new DeleteConnectionController( useCases.deleteConnectionUseCase, ), - getForwardConnectionsForUrlController: - new GetForwardConnectionsForUrlController( - useCases.getForwardConnectionsForUrlUseCase, - ), - getBackwardConnectionsForUrlController: - new GetBackwardConnectionsForUrlController( - useCases.getBackwardConnectionsForUrlUseCase, - ), + getConnectionsForUrlController: new GetConnectionsForUrlController( + useCases.getConnectionsForUrlUseCase, + ), // Graph controllers getGraphDataController: new GetGraphDataController( diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 14591d0e..d4ad5918 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -34,8 +34,7 @@ import { GetUrlStatusForMyLibraryUseCase } from '../../../../modules/cards/appli import { GetLibrariesForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase'; import { GetCollectionsForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase'; import { GetNoteCardsForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase'; -import { GetForwardConnectionsForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetForwardConnectionsForUrlUseCase'; -import { GetBackwardConnectionsForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetBackwardConnectionsForUrlUseCase'; +import { GetConnectionsForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetConnectionsForUrlUseCase'; import { GetConnectionsUseCase } from '../../../../modules/cards/application/useCases/queries/GetConnectionsUseCase'; import { IndexUrlForSearchUseCase } from '../../../../modules/search/application/useCases/commands/IndexUrlForSearchUseCase'; import { GetSimilarUrlsForUrlUseCase } from '../../../../modules/search/application/useCases/queries/GetSimilarUrlsForUrlUseCase'; @@ -142,8 +141,7 @@ export interface UseCases { getLibrariesForUrlUseCase: GetLibrariesForUrlUseCase; getCollectionsForUrlUseCase: GetCollectionsForUrlUseCase; getNoteCardsForUrlUseCase: GetNoteCardsForUrlUseCase; - getForwardConnectionsForUrlUseCase: GetForwardConnectionsForUrlUseCase; - getBackwardConnectionsForUrlUseCase: GetBackwardConnectionsForUrlUseCase; + getConnectionsForUrlUseCase: GetConnectionsForUrlUseCase; // Connection use cases getConnectionsUseCase: GetConnectionsUseCase; createConnectionUseCase: CreateConnectionUseCase; @@ -400,20 +398,12 @@ export class UseCaseFactory { repositories.cardQueryRepository, services.profileService, ), - getForwardConnectionsForUrlUseCase: - new GetForwardConnectionsForUrlUseCase( - repositories.connectionQueryRepository, - repositories.cardQueryRepository, - services.profileService, - services.metadataService, - ), - getBackwardConnectionsForUrlUseCase: - new GetBackwardConnectionsForUrlUseCase( - repositories.connectionQueryRepository, - repositories.cardQueryRepository, - services.profileService, - services.metadataService, - ), + getConnectionsForUrlUseCase: new GetConnectionsForUrlUseCase( + repositories.connectionQueryRepository, + repositories.cardQueryRepository, + services.profileService, + services.metadataService, + ), // Connection use cases getConnectionsUseCase: new GetConnectionsUseCase( diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index 61516710..4c9be5dd 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -347,6 +347,12 @@ export interface GetBackwardConnectionsForUrlParams connectionTypes?: ConnectionType[]; } +export interface GetConnectionsForUrlParams extends PaginatedSortedParams { + url: string; + direction?: 'forward' | 'backward' | 'both'; + connectionTypes?: ConnectionType[]; +} + // Search URLs request types export interface SearchUrlsParams extends PaginatedSortedParams { searchQuery: string; diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 83d0adde..1dd637d1 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -468,6 +468,12 @@ export interface GetBackwardConnectionsForUrlResponse { sorting: ConnectionSorting; } +export interface GetConnectionsForUrlResponse { + connections: ConnectionWithSourceAndTarget[]; + pagination: Pagination; + sorting: ConnectionSorting; +} + // Search URLs response types export interface SearchUrlsResponse { urls: UrlView[]; @@ -483,6 +489,7 @@ export interface ConnectionWithSourceAndTarget { note?: string; createdAt: string; updatedAt: string; + curator: User; }; source: UrlView; target: UrlView; diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 1e174e30..8c0406c2 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -114,6 +114,8 @@ import type { GetForwardConnectionsForUrlResponse, GetBackwardConnectionsForUrlParams, GetBackwardConnectionsForUrlResponse, + GetConnectionsForUrlParams, + GetConnectionsForUrlResponse, ConnectionType, // Search types SearchUrlsParams, @@ -518,6 +520,12 @@ export class ApiClient { return this.queryClient.getBackwardConnectionsForUrl(params); } + async getConnectionsForUrl( + params: GetConnectionsForUrlParams, + ): Promise { + return this.queryClient.getConnectionsForUrl(params); + } + // Search operations async searchUrls(params: SearchUrlsParams): Promise { return this.queryClient.searchUrls(params); diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index f09a51b9..0200285c 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -59,6 +59,8 @@ import { SearchUrlsResponse, GetGraphDataParams, GetGraphDataResponse, + GetConnectionsForUrlParams, + GetConnectionsForUrlResponse, } from '@semble/types'; export class QueryClient extends BaseClient { @@ -538,6 +540,26 @@ export class QueryClient extends BaseClient { ); } + async getConnectionsForUrl( + params: GetConnectionsForUrlParams, + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('url', params.url); + if (params.direction) searchParams.set('direction', params.direction); + if (params.page) searchParams.set('page', params.page.toString()); + if (params.limit) searchParams.set('limit', params.limit.toString()); + if (params.sortBy) searchParams.set('sortBy', params.sortBy); + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); + if (params.connectionTypes) { + searchParams.set('connectionTypes', params.connectionTypes.join(',')); + } + + return this.request( + 'GET', + `/api/connections/url?${searchParams}`, + ); + } + async getConnections( params: GetConnectionsParams, ): Promise { -- 2.51.2