diff --git a/src/modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController.ts b/src/modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController.ts new file mode 100644 index 00000000..008bfdb4 --- /dev/null +++ b/src/modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController.ts @@ -0,0 +1,35 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetSimilarUrlsForUrlUseCase } from '../../../application/useCases/queries/GetSimilarUrlsForUrlUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class GetSimilarUrlsForUrlController extends Controller { + constructor(private getSimilarUrlsForUrlUseCase: GetSimilarUrlsForUrlUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { url, page, limit, threshold } = req.query; + + if (!url || typeof url !== 'string') { + return this.fail(res, 'URL parameter is required'); + } + + const result = await this.getSimilarUrlsForUrlUseCase.execute({ + url, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit as string) : undefined, + threshold: threshold ? parseFloat(threshold as string) : undefined, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/search/infrastructure/http/routes/searchRoutes.ts b/src/modules/search/infrastructure/http/routes/searchRoutes.ts new file mode 100644 index 00000000..01008cd5 --- /dev/null +++ b/src/modules/search/infrastructure/http/routes/searchRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { GetSimilarUrlsForUrlController } from '../controllers/GetSimilarUrlsForUrlController'; +import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export function createSearchRoutes( + authMiddleware: AuthMiddleware, + getSimilarUrlsForUrlController: GetSimilarUrlsForUrlController, +): Router { + const router = Router(); + + // GET /api/search/similar-urls - Get similar URLs for a given URL + router.get('/similar-urls', authMiddleware.optionalAuth(), (req, res) => + getSimilarUrlsForUrlController.execute(req, res), + ); + + return router; +} diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 1de72a3d..7a1d7985 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -6,6 +6,7 @@ import { createUserRoutes } from '../../../modules/user/infrastructure/http/rout import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes'; +import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes'; import { EnvironmentConfigService, Environment, @@ -118,11 +119,17 @@ export const createExpressApp = ( controllers.getGlobalFeedController, ); + const searchRouter = createSearchRoutes( + services.authMiddleware, + controllers.getSimilarUrlsForUrlController, + ); + // Register routes app.use('/api/users', userRouter); app.use('/atproto', atprotoRouter); app.use('/api', cardsRouter); app.use('/api/feeds', feedRouter); + app.use('/api/search', searchRouter); return app; }; diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index f7018618..6c03c01a 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -19,6 +19,7 @@ import { DeleteCollectionController } from '../../../../modules/cards/infrastruc import { GetCollectionPageController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionPageController'; import { GetMyCollectionsController } from '../../../../modules/cards/infrastructure/http/controllers/GetMyCollectionsController'; import { GetGlobalFeedController } from '../../../../modules/feeds/infrastructure/http/controllers/GetGlobalFeedController'; +import { GetSimilarUrlsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController'; import { UseCases } from './UseCaseFactory'; import { GetMyProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetMyProfileController'; import { GetUserProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetUserProfileController'; @@ -183,6 +184,11 @@ export class ControllerFactory { getGlobalFeedController: new GetGlobalFeedController( useCases.getGlobalFeedUseCase, ), + // Search controllers + getSimilarUrlsForUrlController: new GetSimilarUrlsForUrlController( + useCases.getSimilarUrlsForUrlUseCase, + ), }; } } +import { GetSimilarUrlsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController'; diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index e5d5d5a0..45641139 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -135,3 +135,8 @@ export interface GetNoteCardsForUrlParams extends PaginatedSortedParams { export interface GetCollectionsForUrlParams extends PaginatedSortedParams { url: string; } + +export interface GetSimilarUrlsForUrlParams extends PaginatedSortedParams { + url: string; + threshold?: number; +} diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 5209ba4d..f39e4a06 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -62,6 +62,8 @@ import type { GetNoteCardsForUrlResponse, GetCollectionsForUrlParams, GetCollectionsForUrlResponse, + GetSimilarUrlsForUrlParams, + GetSimilarUrlsForUrlResponse, } from '@semble/types'; // Main API Client class using composition @@ -162,6 +164,12 @@ export class ApiClient { return this.queryClient.getCollectionsForUrl(params); } + async getSimilarUrlsForUrl( + params: GetSimilarUrlsForUrlParams, + ): Promise { + return this.queryClient.getSimilarUrlsForUrl(params); + } + // Card operations - delegate to CardClient async addUrlToLibrary( request: AddUrlToLibraryRequest, diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index 362048b0..b464c983 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -221,3 +221,277 @@ export class QueryClient extends BaseClient { ); } } +import type { + GetUrlMetadataResponse, + GetMyUrlCardsParams, + GetUrlCardsParams, + GetUrlCardsResponse, + GetUrlCardViewResponse, + GetLibrariesForCardResponse, + GetProfileResponse, + GetProfileParams, + GetCollectionPageParams, + GetCollectionPageResponse, + GetCollectionPageByAtUriParams, + GetMyCollectionsParams, + GetCollectionsResponse, + GetCollectionsParams, + GetUrlStatusForMyLibraryParams, + GetUrlStatusForMyLibraryResponse, + GetLibrariesForUrlParams, + GetLibrariesForUrlResponse, + GetNoteCardsForUrlParams, + GetNoteCardsForUrlResponse, + GetCollectionsForUrlParams, + GetCollectionsForUrlResponse, + GetSimilarUrlsForUrlParams, + GetSimilarUrlsForUrlResponse, +} from '@semble/types'; + +export class QueryClient { + constructor(private baseUrl: string) {} + + async getUrlMetadata(url: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/cards/metadata?url=${encodeURIComponent(url)}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getMyUrlCards( + params?: GetMyUrlCardsParams, + ): Promise { + const searchParams = new URLSearchParams(); + 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); + + const response = await fetch( + `${this.baseUrl}/api/cards/my?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getUserUrlCards(params: GetUrlCardsParams): Promise { + const searchParams = new URLSearchParams(); + 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); + + const response = await fetch( + `${this.baseUrl}/api/cards/user/${params.identifier}?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getUrlCardView(cardId: string): Promise { + const response = await fetch(`${this.baseUrl}/api/cards/${cardId}`, { + credentials: 'include', + }); + return response.json(); + } + + async getLibrariesForCard( + cardId: string, + ): Promise { + const response = await fetch( + `${this.baseUrl}/api/cards/${cardId}/libraries`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getMyProfile(): Promise { + const response = await fetch(`${this.baseUrl}/api/users/me`, { + credentials: 'include', + }); + return response.json(); + } + + async getUserProfile(params: GetProfileParams): Promise { + const response = await fetch( + `${this.baseUrl}/api/users/${params.identifier}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getCollectionPage( + collectionId: string, + params?: GetCollectionPageParams, + ): Promise { + const searchParams = new URLSearchParams(); + 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); + + const response = await fetch( + `${this.baseUrl}/api/collections/${collectionId}?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getCollectionPageByAtUri( + params: GetCollectionPageByAtUriParams, + ): Promise { + const searchParams = new URLSearchParams(); + 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); + + const response = await fetch( + `${this.baseUrl}/api/collections/at-uri/${params.handle}/${params.recordKey}?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getMyCollections( + params?: GetMyCollectionsParams, + ): Promise { + const searchParams = new URLSearchParams(); + 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?.searchText) searchParams.set('searchText', params.searchText); + + const response = await fetch( + `${this.baseUrl}/api/collections/my?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getUserCollections( + params: GetCollectionsParams, + ): Promise { + const searchParams = new URLSearchParams(); + 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.searchText) searchParams.set('searchText', params.searchText); + + const response = await fetch( + `${this.baseUrl}/api/collections/user/${params.identifier}?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getUrlStatusForMyLibrary( + params: GetUrlStatusForMyLibraryParams, + ): Promise { + const response = await fetch( + `${this.baseUrl}/api/cards/library/status?url=${encodeURIComponent(params.url)}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getLibrariesForUrl( + params: GetLibrariesForUrlParams, + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('url', params.url); + 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); + + const response = await fetch( + `${this.baseUrl}/api/cards/libraries/url?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getNoteCardsForUrl( + params: GetNoteCardsForUrlParams, + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('url', params.url); + 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); + + const response = await fetch( + `${this.baseUrl}/api/cards/notes/url?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getCollectionsForUrl( + params: GetCollectionsForUrlParams, + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('url', params.url); + 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); + + const response = await fetch( + `${this.baseUrl}/api/cards/collections/url?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } + + async getSimilarUrlsForUrl( + params: GetSimilarUrlsForUrlParams, + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('url', params.url); + 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.threshold) searchParams.set('threshold', params.threshold.toString()); + + const response = await fetch( + `${this.baseUrl}/api/search/similar-urls?${searchParams}`, + { + credentials: 'include', + }, + ); + return response.json(); + } +}