A social knowledge tool for researchers built on ATProto

feat: add search functionality with similar URLs endpoint

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+352
+35
src/modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { GetSimilarUrlsForUrlUseCase } from '../../../application/useCases/queries/GetSimilarUrlsForUrlUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + 6 + export class GetSimilarUrlsForUrlController extends Controller { 7 + constructor(private getSimilarUrlsForUrlUseCase: GetSimilarUrlsForUrlUseCase) { 8 + super(); 9 + } 10 + 11 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 12 + try { 13 + const { url, page, limit, threshold } = req.query; 14 + 15 + if (!url || typeof url !== 'string') { 16 + return this.fail(res, 'URL parameter is required'); 17 + } 18 + 19 + const result = await this.getSimilarUrlsForUrlUseCase.execute({ 20 + url, 21 + page: page ? parseInt(page as string) : undefined, 22 + limit: limit ? parseInt(limit as string) : undefined, 23 + threshold: threshold ? parseFloat(threshold as string) : undefined, 24 + }); 25 + 26 + if (result.isErr()) { 27 + return this.fail(res, result.error); 28 + } 29 + 30 + return this.ok(res, result.value); 31 + } catch (error: any) { 32 + return this.fail(res, error); 33 + } 34 + } 35 + }
+17
src/modules/search/infrastructure/http/routes/searchRoutes.ts
··· 1 + import { Router } from 'express'; 2 + import { GetSimilarUrlsForUrlController } from '../controllers/GetSimilarUrlsForUrlController'; 3 + import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 4 + 5 + export function createSearchRoutes( 6 + authMiddleware: AuthMiddleware, 7 + getSimilarUrlsForUrlController: GetSimilarUrlsForUrlController, 8 + ): Router { 9 + const router = Router(); 10 + 11 + // GET /api/search/similar-urls - Get similar URLs for a given URL 12 + router.get('/similar-urls', authMiddleware.optionalAuth(), (req, res) => 13 + getSimilarUrlsForUrlController.execute(req, res), 14 + ); 15 + 16 + return router; 17 + }
+7
src/shared/infrastructure/http/app.ts
··· 6 6 import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; 7 7 import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; 8 8 import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes'; 9 + import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes'; 9 10 import { 10 11 EnvironmentConfigService, 11 12 Environment, ··· 118 119 controllers.getGlobalFeedController, 119 120 ); 120 121 122 + const searchRouter = createSearchRoutes( 123 + services.authMiddleware, 124 + controllers.getSimilarUrlsForUrlController, 125 + ); 126 + 121 127 // Register routes 122 128 app.use('/api/users', userRouter); 123 129 app.use('/atproto', atprotoRouter); 124 130 app.use('/api', cardsRouter); 125 131 app.use('/api/feeds', feedRouter); 132 + app.use('/api/search', searchRouter); 126 133 127 134 return app; 128 135 };
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 19 19 import { GetCollectionPageController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionPageController'; 20 20 import { GetMyCollectionsController } from '../../../../modules/cards/infrastructure/http/controllers/GetMyCollectionsController'; 21 21 import { GetGlobalFeedController } from '../../../../modules/feeds/infrastructure/http/controllers/GetGlobalFeedController'; 22 + import { GetSimilarUrlsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController'; 22 23 import { UseCases } from './UseCaseFactory'; 23 24 import { GetMyProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetMyProfileController'; 24 25 import { GetUserProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetUserProfileController'; ··· 183 184 getGlobalFeedController: new GetGlobalFeedController( 184 185 useCases.getGlobalFeedUseCase, 185 186 ), 187 + // Search controllers 188 + getSimilarUrlsForUrlController: new GetSimilarUrlsForUrlController( 189 + useCases.getSimilarUrlsForUrlUseCase, 190 + ), 186 191 }; 187 192 } 188 193 } 194 + import { GetSimilarUrlsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController';
+5
src/types/src/api/requests.ts
··· 135 135 export interface GetCollectionsForUrlParams extends PaginatedSortedParams { 136 136 url: string; 137 137 } 138 + 139 + export interface GetSimilarUrlsForUrlParams extends PaginatedSortedParams { 140 + url: string; 141 + threshold?: number; 142 + }
+8
src/webapp/api-client/ApiClient.ts
··· 62 62 GetNoteCardsForUrlResponse, 63 63 GetCollectionsForUrlParams, 64 64 GetCollectionsForUrlResponse, 65 + GetSimilarUrlsForUrlParams, 66 + GetSimilarUrlsForUrlResponse, 65 67 } from '@semble/types'; 66 68 67 69 // Main API Client class using composition ··· 160 162 params: GetCollectionsForUrlParams, 161 163 ): Promise<GetCollectionsForUrlResponse> { 162 164 return this.queryClient.getCollectionsForUrl(params); 165 + } 166 + 167 + async getSimilarUrlsForUrl( 168 + params: GetSimilarUrlsForUrlParams, 169 + ): Promise<GetSimilarUrlsForUrlResponse> { 170 + return this.queryClient.getSimilarUrlsForUrl(params); 163 171 } 164 172 165 173 // Card operations - delegate to CardClient
+274
src/webapp/api-client/clients/QueryClient.ts
··· 221 221 ); 222 222 } 223 223 } 224 + import type { 225 + GetUrlMetadataResponse, 226 + GetMyUrlCardsParams, 227 + GetUrlCardsParams, 228 + GetUrlCardsResponse, 229 + GetUrlCardViewResponse, 230 + GetLibrariesForCardResponse, 231 + GetProfileResponse, 232 + GetProfileParams, 233 + GetCollectionPageParams, 234 + GetCollectionPageResponse, 235 + GetCollectionPageByAtUriParams, 236 + GetMyCollectionsParams, 237 + GetCollectionsResponse, 238 + GetCollectionsParams, 239 + GetUrlStatusForMyLibraryParams, 240 + GetUrlStatusForMyLibraryResponse, 241 + GetLibrariesForUrlParams, 242 + GetLibrariesForUrlResponse, 243 + GetNoteCardsForUrlParams, 244 + GetNoteCardsForUrlResponse, 245 + GetCollectionsForUrlParams, 246 + GetCollectionsForUrlResponse, 247 + GetSimilarUrlsForUrlParams, 248 + GetSimilarUrlsForUrlResponse, 249 + } from '@semble/types'; 250 + 251 + export class QueryClient { 252 + constructor(private baseUrl: string) {} 253 + 254 + async getUrlMetadata(url: string): Promise<GetUrlMetadataResponse> { 255 + const response = await fetch( 256 + `${this.baseUrl}/api/cards/metadata?url=${encodeURIComponent(url)}`, 257 + { 258 + credentials: 'include', 259 + }, 260 + ); 261 + return response.json(); 262 + } 263 + 264 + async getMyUrlCards( 265 + params?: GetMyUrlCardsParams, 266 + ): Promise<GetUrlCardsResponse> { 267 + const searchParams = new URLSearchParams(); 268 + if (params?.page) searchParams.set('page', params.page.toString()); 269 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 270 + if (params?.sortBy) searchParams.set('sortBy', params.sortBy); 271 + if (params?.sortOrder) searchParams.set('sortOrder', params.sortOrder); 272 + 273 + const response = await fetch( 274 + `${this.baseUrl}/api/cards/my?${searchParams}`, 275 + { 276 + credentials: 'include', 277 + }, 278 + ); 279 + return response.json(); 280 + } 281 + 282 + async getUserUrlCards(params: GetUrlCardsParams): Promise<GetUrlCardsResponse> { 283 + const searchParams = new URLSearchParams(); 284 + if (params.page) searchParams.set('page', params.page.toString()); 285 + if (params.limit) searchParams.set('limit', params.limit.toString()); 286 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 287 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 288 + 289 + const response = await fetch( 290 + `${this.baseUrl}/api/cards/user/${params.identifier}?${searchParams}`, 291 + { 292 + credentials: 'include', 293 + }, 294 + ); 295 + return response.json(); 296 + } 297 + 298 + async getUrlCardView(cardId: string): Promise<GetUrlCardViewResponse> { 299 + const response = await fetch(`${this.baseUrl}/api/cards/${cardId}`, { 300 + credentials: 'include', 301 + }); 302 + return response.json(); 303 + } 304 + 305 + async getLibrariesForCard( 306 + cardId: string, 307 + ): Promise<GetLibrariesForCardResponse> { 308 + const response = await fetch( 309 + `${this.baseUrl}/api/cards/${cardId}/libraries`, 310 + { 311 + credentials: 'include', 312 + }, 313 + ); 314 + return response.json(); 315 + } 316 + 317 + async getMyProfile(): Promise<GetProfileResponse> { 318 + const response = await fetch(`${this.baseUrl}/api/users/me`, { 319 + credentials: 'include', 320 + }); 321 + return response.json(); 322 + } 323 + 324 + async getUserProfile(params: GetProfileParams): Promise<GetProfileResponse> { 325 + const response = await fetch( 326 + `${this.baseUrl}/api/users/${params.identifier}`, 327 + { 328 + credentials: 'include', 329 + }, 330 + ); 331 + return response.json(); 332 + } 333 + 334 + async getCollectionPage( 335 + collectionId: string, 336 + params?: GetCollectionPageParams, 337 + ): Promise<GetCollectionPageResponse> { 338 + const searchParams = new URLSearchParams(); 339 + if (params?.page) searchParams.set('page', params.page.toString()); 340 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 341 + if (params?.sortBy) searchParams.set('sortBy', params.sortBy); 342 + if (params?.sortOrder) searchParams.set('sortOrder', params.sortOrder); 343 + 344 + const response = await fetch( 345 + `${this.baseUrl}/api/collections/${collectionId}?${searchParams}`, 346 + { 347 + credentials: 'include', 348 + }, 349 + ); 350 + return response.json(); 351 + } 352 + 353 + async getCollectionPageByAtUri( 354 + params: GetCollectionPageByAtUriParams, 355 + ): Promise<GetCollectionPageResponse> { 356 + const searchParams = new URLSearchParams(); 357 + if (params.page) searchParams.set('page', params.page.toString()); 358 + if (params.limit) searchParams.set('limit', params.limit.toString()); 359 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 360 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 361 + 362 + const response = await fetch( 363 + `${this.baseUrl}/api/collections/at-uri/${params.handle}/${params.recordKey}?${searchParams}`, 364 + { 365 + credentials: 'include', 366 + }, 367 + ); 368 + return response.json(); 369 + } 370 + 371 + async getMyCollections( 372 + params?: GetMyCollectionsParams, 373 + ): Promise<GetCollectionsResponse> { 374 + const searchParams = new URLSearchParams(); 375 + if (params?.page) searchParams.set('page', params.page.toString()); 376 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 377 + if (params?.sortBy) searchParams.set('sortBy', params.sortBy); 378 + if (params?.sortOrder) searchParams.set('sortOrder', params.sortOrder); 379 + if (params?.searchText) searchParams.set('searchText', params.searchText); 380 + 381 + const response = await fetch( 382 + `${this.baseUrl}/api/collections/my?${searchParams}`, 383 + { 384 + credentials: 'include', 385 + }, 386 + ); 387 + return response.json(); 388 + } 389 + 390 + async getUserCollections( 391 + params: GetCollectionsParams, 392 + ): Promise<GetCollectionsResponse> { 393 + const searchParams = new URLSearchParams(); 394 + if (params.page) searchParams.set('page', params.page.toString()); 395 + if (params.limit) searchParams.set('limit', params.limit.toString()); 396 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 397 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 398 + if (params.searchText) searchParams.set('searchText', params.searchText); 399 + 400 + const response = await fetch( 401 + `${this.baseUrl}/api/collections/user/${params.identifier}?${searchParams}`, 402 + { 403 + credentials: 'include', 404 + }, 405 + ); 406 + return response.json(); 407 + } 408 + 409 + async getUrlStatusForMyLibrary( 410 + params: GetUrlStatusForMyLibraryParams, 411 + ): Promise<GetUrlStatusForMyLibraryResponse> { 412 + const response = await fetch( 413 + `${this.baseUrl}/api/cards/library/status?url=${encodeURIComponent(params.url)}`, 414 + { 415 + credentials: 'include', 416 + }, 417 + ); 418 + return response.json(); 419 + } 420 + 421 + async getLibrariesForUrl( 422 + params: GetLibrariesForUrlParams, 423 + ): Promise<GetLibrariesForUrlResponse> { 424 + const searchParams = new URLSearchParams(); 425 + searchParams.set('url', params.url); 426 + if (params.page) searchParams.set('page', params.page.toString()); 427 + if (params.limit) searchParams.set('limit', params.limit.toString()); 428 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 429 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 430 + 431 + const response = await fetch( 432 + `${this.baseUrl}/api/cards/libraries/url?${searchParams}`, 433 + { 434 + credentials: 'include', 435 + }, 436 + ); 437 + return response.json(); 438 + } 439 + 440 + async getNoteCardsForUrl( 441 + params: GetNoteCardsForUrlParams, 442 + ): Promise<GetNoteCardsForUrlResponse> { 443 + const searchParams = new URLSearchParams(); 444 + searchParams.set('url', params.url); 445 + if (params.page) searchParams.set('page', params.page.toString()); 446 + if (params.limit) searchParams.set('limit', params.limit.toString()); 447 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 448 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 449 + 450 + const response = await fetch( 451 + `${this.baseUrl}/api/cards/notes/url?${searchParams}`, 452 + { 453 + credentials: 'include', 454 + }, 455 + ); 456 + return response.json(); 457 + } 458 + 459 + async getCollectionsForUrl( 460 + params: GetCollectionsForUrlParams, 461 + ): Promise<GetCollectionsForUrlResponse> { 462 + const searchParams = new URLSearchParams(); 463 + searchParams.set('url', params.url); 464 + if (params.page) searchParams.set('page', params.page.toString()); 465 + if (params.limit) searchParams.set('limit', params.limit.toString()); 466 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 467 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 468 + 469 + const response = await fetch( 470 + `${this.baseUrl}/api/cards/collections/url?${searchParams}`, 471 + { 472 + credentials: 'include', 473 + }, 474 + ); 475 + return response.json(); 476 + } 477 + 478 + async getSimilarUrlsForUrl( 479 + params: GetSimilarUrlsForUrlParams, 480 + ): Promise<GetSimilarUrlsForUrlResponse> { 481 + const searchParams = new URLSearchParams(); 482 + searchParams.set('url', params.url); 483 + if (params.page) searchParams.set('page', params.page.toString()); 484 + if (params.limit) searchParams.set('limit', params.limit.toString()); 485 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 486 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 487 + if (params.threshold) searchParams.set('threshold', params.threshold.toString()); 488 + 489 + const response = await fetch( 490 + `${this.baseUrl}/api/search/similar-urls?${searchParams}`, 491 + { 492 + credentials: 'include', 493 + }, 494 + ); 495 + return response.json(); 496 + } 497 + }