diff --git a/src/modules/cards/infrastructure/http/controllers/CreateConnectionController.ts b/src/modules/cards/infrastructure/http/controllers/CreateConnectionController.ts new file mode 100644 index 00000000..bacb890c --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/CreateConnectionController.ts @@ -0,0 +1,81 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { CreateConnectionUseCase } from '../../../application/useCases/commands/CreateConnectionUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export class CreateConnectionController extends Controller { + constructor(private createConnectionUseCase: CreateConnectionUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { + sourceType, + sourceValue, + targetType, + targetValue, + connectionType, + note, + } = req.body; + const curatorId = req.did; + + if (!curatorId) { + return this.unauthorized(res); + } + + if (!sourceType || !sourceValue) { + return this.badRequest(res, 'Source type and value are required'); + } + + if (!targetType || !targetValue) { + return this.badRequest(res, 'Target type and value are required'); + } + + // Validate source and target types + if (!['URL', 'CARD'].includes(sourceType)) { + return this.badRequest(res, 'Invalid source type. Must be URL or CARD'); + } + + if (!['URL', 'CARD'].includes(targetType)) { + return this.badRequest(res, 'Invalid target type. Must be URL or CARD'); + } + + // Validate connectionType if provided + if ( + connectionType !== undefined && + !['CITATION', 'REFERENCE', 'RELATED', 'INSPIRED_BY'].includes( + connectionType, + ) + ) { + return this.badRequest( + res, + 'Invalid connectionType. Must be CITATION, REFERENCE, RELATED, or INSPIRED_BY', + ); + } + + const result = await this.createConnectionUseCase.execute({ + sourceType, + sourceValue, + targetType, + targetValue, + connectionType, + note, + curatorId, + }); + + if (result.isErr()) { + // Check if the error is an authentication error + if (result.error instanceof AuthenticationError) { + return this.unauthorized(res, result.error.message); + } + return this.fail(res, result.error); + } + + return this.created(res, result.value); + } catch (error: any) { + return this.handleError(res, error); + } + } +} diff --git a/src/modules/cards/infrastructure/http/controllers/DeleteConnectionController.ts b/src/modules/cards/infrastructure/http/controllers/DeleteConnectionController.ts new file mode 100644 index 00000000..0c996b34 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/DeleteConnectionController.ts @@ -0,0 +1,43 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { DeleteConnectionUseCase } from '../../../application/useCases/commands/DeleteConnectionUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export class DeleteConnectionController extends Controller { + constructor(private deleteConnectionUseCase: DeleteConnectionUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { connectionId } = req.params; + const curatorId = req.did; + + if (!curatorId) { + return this.unauthorized(res); + } + + if (!connectionId) { + return this.badRequest(res, 'Connection ID is required'); + } + + const result = await this.deleteConnectionUseCase.execute({ + connectionId, + curatorId, + }); + + if (result.isErr()) { + // Check if the error is an authentication error + if (result.error instanceof AuthenticationError) { + return this.unauthorized(res, result.error.message); + } + 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/controllers/GetBackwardConnectionsForUrlController.ts b/src/modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController.ts new file mode 100644 index 00000000..0faca1a9 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController.ts @@ -0,0 +1,65 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetBackwardConnectionsForUrlUseCase } from '../../../application/useCases/queries/GetBackwardConnectionsForUrlUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { + ConnectionSortField, + SortOrder, +} from '../../../domain/IConnectionQueryRepository'; +import { ConnectionTypeEnum } from '../../../domain/value-objects/ConnectionType'; + +export class GetBackwardConnectionsForUrlController extends Controller { + constructor( + private getBackwardConnectionsForUrlUseCase: GetBackwardConnectionsForUrlUseCase, + ) { + 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.getBackwardConnectionsForUrlUseCase.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/controllers/GetForwardConnectionsForUrlController.ts b/src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts new file mode 100644 index 00000000..61d0fff7 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController.ts @@ -0,0 +1,65 @@ +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/controllers/SearchUrlsController.ts b/src/modules/cards/infrastructure/http/controllers/SearchUrlsController.ts new file mode 100644 index 00000000..b3f29be3 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/SearchUrlsController.ts @@ -0,0 +1,55 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { SearchUrlsUseCase } from '../../../application/useCases/queries/SearchUrlsUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { CardSortField, SortOrder } from '../../../domain/ICardQueryRepository'; +import { UrlType } from '../../../domain/value-objects/UrlType'; + +export class SearchUrlsController extends Controller { + constructor(private searchUrlsUseCase: SearchUrlsUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { searchQuery } = req.query; + const callingUserId = req.did; + + if (!searchQuery || typeof searchQuery !== 'string') { + return this.badRequest(res, 'Search query 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 CardSortField) || CardSortField.UPDATED_AT; + const sortOrder = (req.query.sortOrder as SortOrder) || SortOrder.DESC; + + // Parse urlType filter + const urlType = req.query.urlType as UrlType | undefined; + + const result = await this.searchUrlsUseCase.execute({ + searchQuery, + callingUserId, + page, + limit, + sortBy, + sortOrder, + urlType, + }); + + 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/controllers/UpdateConnectionController.ts b/src/modules/cards/infrastructure/http/controllers/UpdateConnectionController.ts new file mode 100644 index 00000000..b4b85405 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/UpdateConnectionController.ts @@ -0,0 +1,53 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { UpdateConnectionUseCase } from '../../../application/useCases/commands/UpdateConnectionUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export class UpdateConnectionController extends Controller { + constructor(private updateConnectionUseCase: UpdateConnectionUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { connectionId } = req.params; + const { note, removeNote } = req.body; + const curatorId = req.did; + + if (!curatorId) { + return this.unauthorized(res); + } + + if (!connectionId) { + return this.badRequest(res, 'Connection ID is required'); + } + + if (note === undefined && removeNote === undefined) { + return this.badRequest( + res, + 'Either note or removeNote must be provided', + ); + } + + const result = await this.updateConnectionUseCase.execute({ + connectionId, + note, + removeNote, + curatorId, + }); + + if (result.isErr()) { + // Check if the error is an authentication error + if (result.error instanceof AuthenticationError) { + return this.unauthorized(res, result.error.message); + } + 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/cardRoutes.ts b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts index da5c67ba..cd6378b0 100644 --- a/src/modules/cards/infrastructure/http/routes/cardRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/cardRoutes.ts @@ -14,6 +14,7 @@ import { GetUserUrlCardsController } from '../controllers/GetUserUrlCardsControl import { GetUrlStatusForMyLibraryController } from '../controllers/GetUrlStatusForMyLibraryController'; import { GetLibrariesForUrlController } from '../controllers/GetLibrariesForUrlController'; import { GetNoteCardsForUrlController } from '../controllers/GetNoteCardsForUrlController'; +import { SearchUrlsController } from '../controllers/SearchUrlsController'; import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; export function createCardRoutes( @@ -33,10 +34,16 @@ export function createCardRoutes( getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, getLibrariesForUrlController: GetLibrariesForUrlController, getNoteCardsForUrlController: GetNoteCardsForUrlController, + searchUrlsController: SearchUrlsController, ): Router { const router = Router(); // Query routes + // GET /api/cards/search - Search URLs + router.get('/search', authMiddleware.optionalAuth(), (req, res) => + searchUrlsController.execute(req, res), + ); + // GET /api/cards/metadata - Get URL metadata router.get('/metadata', authMiddleware.optionalAuth(), (req, res) => getUrlMetadataController.execute(req, res), diff --git a/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts b/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts new file mode 100644 index 00000000..f29c4de3 --- /dev/null +++ b/src/modules/cards/infrastructure/http/routes/connectionRoutes.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { CreateConnectionController } from '../controllers/CreateConnectionController'; +import { UpdateConnectionController } from '../controllers/UpdateConnectionController'; +import { DeleteConnectionController } from '../controllers/DeleteConnectionController'; +import { GetForwardConnectionsForUrlController } from '../controllers/GetForwardConnectionsForUrlController'; +import { GetBackwardConnectionsForUrlController } from '../controllers/GetBackwardConnectionsForUrlController'; +import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; + +export function createConnectionRoutes( + authMiddleware: AuthMiddleware, + createConnectionController: CreateConnectionController, + updateConnectionController: UpdateConnectionController, + deleteConnectionController: DeleteConnectionController, + getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController, + getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController, +): Router { + const router = Router(); + + // Query routes + // 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), + ); + + // Command routes + // POST /api/connections - Create connection + router.post('/', authMiddleware.ensureAuthenticated(), (req, res) => + createConnectionController.execute(req, res), + ); + + // PUT /api/connections/:connectionId - Update connection + router.put( + '/:connectionId', + authMiddleware.ensureAuthenticated(), + (req, res) => updateConnectionController.execute(req, res), + ); + + // DELETE /api/connections/:connectionId - Delete connection + router.delete( + '/:connectionId', + authMiddleware.ensureAuthenticated(), + (req, res) => deleteConnectionController.execute(req, res), + ); + + return router; +} diff --git a/src/modules/cards/infrastructure/http/routes/index.ts b/src/modules/cards/infrastructure/http/routes/index.ts index 7cb51727..33f6670d 100644 --- a/src/modules/cards/infrastructure/http/routes/index.ts +++ b/src/modules/cards/infrastructure/http/routes/index.ts @@ -30,6 +30,7 @@ import { GetCollectionsForUrlController } from '../controllers/GetCollectionsFor import { GetCollectionFollowersController } from '../controllers/GetCollectionFollowersController'; import { GetCollectionFollowersCountController } from '../controllers/GetCollectionFollowersCountController'; import { GetCollectionContributorsController } from '../controllers/GetCollectionContributorsController'; +import { SearchUrlsController } from '../controllers/SearchUrlsController'; export function createCardsModuleRoutes( authMiddleware: AuthMiddleware, @@ -49,6 +50,7 @@ export function createCardsModuleRoutes( getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController, getLibrariesForUrlController: GetLibrariesForUrlController, getNoteCardsForUrlController: GetNoteCardsForUrlController, + searchUrlsController: SearchUrlsController, // Collection controllers createCollectionController: CreateCollectionController, updateCollectionController: UpdateCollectionController, @@ -86,6 +88,7 @@ export function createCardsModuleRoutes( getUrlStatusForMyLibraryController, getLibrariesForUrlController, getNoteCardsForUrlController, + searchUrlsController, ), ); diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 7ffc146a..346d6456 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -5,6 +5,7 @@ import { Router } from 'express'; import { createUserRoutes } from '../../../modules/user/infrastructure/http/routes/userRoutes'; import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; +import { createConnectionRoutes } from '../../../modules/cards/infrastructure/http/routes/connectionRoutes'; import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes'; import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes'; import { createNotificationRoutes } from '../../../modules/notifications/infrastructure/http/routes/notificationRoutes'; @@ -113,6 +114,7 @@ export const createExpressApp = ( controllers.getUrlStatusForMyLibraryController, controllers.getLibrariesForUrlController, controllers.getNoteCardsForUrlController, + controllers.searchUrlsController, // Collection controllers controllers.createCollectionController, controllers.updateCollectionController, @@ -129,6 +131,15 @@ export const createExpressApp = ( controllers.getCollectionContributorsController, ); + const connectionRouter = createConnectionRoutes( + services.authMiddleware, + controllers.createConnectionController, + controllers.updateConnectionController, + controllers.deleteConnectionController, + controllers.getForwardConnectionsForUrlController, + controllers.getBackwardConnectionsForUrlController, + ); + const feedRouter = createFeedRoutes( services.authMiddleware, controllers.getGlobalFeedController, @@ -160,6 +171,7 @@ export const createExpressApp = ( app.use('/api/users', userRouter); app.use('/atproto', atprotoRouter); app.use('/api', cardsRouter); + app.use('/api/connections', connectionRouter); app.use('/api/feeds', feedRouter); app.use('/api/search', searchRouter); app.use('/api/notifications', notificationRouter); diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index d26359bf..b18bb0c9 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -55,6 +55,12 @@ import { GetFollowingCollectionsCountController } from '../../../../modules/user import { GetCollectionFollowersController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersController'; import { GetCollectionFollowersCountController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController'; import { GetCollectionContributorsController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionContributorsController'; +import { CreateConnectionController } from '../../../../modules/cards/infrastructure/http/controllers/CreateConnectionController'; +import { UpdateConnectionController } from '../../../../modules/cards/infrastructure/http/controllers/UpdateConnectionController'; +import { DeleteConnectionController } from '../../../../modules/cards/infrastructure/http/controllers/DeleteConnectionController'; +import { GetForwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetForwardConnectionsForUrlController'; +import { GetBackwardConnectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetBackwardConnectionsForUrlController'; +import { SearchUrlsController } from '../../../../modules/cards/infrastructure/http/controllers/SearchUrlsController'; import { CookieService } from '../services/CookieService'; export interface Controllers { @@ -104,6 +110,14 @@ export interface Controllers { getCollectionFollowersController: GetCollectionFollowersController; getCollectionFollowersCountController: GetCollectionFollowersCountController; getCollectionContributorsController: GetCollectionContributorsController; + // Connection controllers + createConnectionController: CreateConnectionController; + updateConnectionController: UpdateConnectionController; + deleteConnectionController: DeleteConnectionController; + getForwardConnectionsForUrlController: GetForwardConnectionsForUrlController; + getBackwardConnectionsForUrlController: GetBackwardConnectionsForUrlController; + // Search controllers + searchUrlsController: SearchUrlsController; // Feed controllers getGlobalFeedController: GetGlobalFeedController; getGemActivityFeedController: GetGemActivityFeedController; @@ -272,6 +286,30 @@ export class ControllerFactory { useCases.getCollectionContributorsUseCase, ), + // Connection controllers + createConnectionController: new CreateConnectionController( + useCases.createConnectionUseCase, + ), + updateConnectionController: new UpdateConnectionController( + useCases.updateConnectionUseCase, + ), + deleteConnectionController: new DeleteConnectionController( + useCases.deleteConnectionUseCase, + ), + getForwardConnectionsForUrlController: + new GetForwardConnectionsForUrlController( + useCases.getForwardConnectionsForUrlUseCase, + ), + getBackwardConnectionsForUrlController: + new GetBackwardConnectionsForUrlController( + useCases.getBackwardConnectionsForUrlUseCase, + ), + + // Search controllers + searchUrlsController: new SearchUrlsController( + useCases.searchUrlsUseCase, + ), + // Feed controllers getGlobalFeedController: new GetGlobalFeedController( useCases.getGlobalFeedUseCase, diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index cf987006..e7b65996 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -71,6 +71,7 @@ import { GetFollowersCountUseCase } from '../../../../modules/user/application/u import { GetFollowingCollectionsCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase'; import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase'; import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase'; +import { SearchUrlsUseCase } from '../../../../modules/cards/application/useCases/queries/SearchUrlsUseCase'; export interface WorkerUseCases { addActivityToFeedUseCase: AddActivityToFeedUseCase; @@ -141,6 +142,12 @@ export interface UseCases { getNoteCardsForUrlUseCase: GetNoteCardsForUrlUseCase; getForwardConnectionsForUrlUseCase: GetForwardConnectionsForUrlUseCase; getBackwardConnectionsForUrlUseCase: GetBackwardConnectionsForUrlUseCase; + // Connection use cases + createConnectionUseCase: CreateConnectionUseCase; + updateConnectionUseCase: UpdateConnectionUseCase; + deleteConnectionUseCase: DeleteConnectionUseCase; + // Search use cases + searchUrlsUseCase: SearchUrlsUseCase; // Feed use cases getGlobalFeedUseCase: GetGlobalFeedUseCase; getGemActivityFeedUseCase: GetGemActivityFeedUseCase; @@ -394,6 +401,27 @@ export class UseCaseFactory { services.profileService, ), + // Connection use cases + createConnectionUseCase: new CreateConnectionUseCase( + repositories.connectionRepository, + services.connectionPublisher, + services.eventPublisher, + ), + updateConnectionUseCase: new UpdateConnectionUseCase( + repositories.connectionRepository, + services.connectionPublisher, + ), + deleteConnectionUseCase: new DeleteConnectionUseCase( + repositories.connectionRepository, + services.connectionPublisher, + services.eventPublisher, + ), + + // Search use cases + searchUrlsUseCase: new SearchUrlsUseCase( + repositories.cardQueryRepository, + ), + // Feed use cases getGlobalFeedUseCase: new GetGlobalFeedUseCase( repositories.feedRepository, diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index e092a6b9..60f19930 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -296,3 +296,41 @@ export interface GetCollectionFollowersCountParams { export interface GetCollectionContributorsParams extends PaginationParams { collectionId: string; // Collection UUID } + +// Connection request types +export interface CreateConnectionRequest { + sourceType: 'URL' | 'CARD'; + sourceValue: string; + targetType: 'URL' | 'CARD'; + targetValue: string; + connectionType?: 'CITATION' | 'REFERENCE' | 'RELATED' | 'INSPIRED_BY'; + note?: string; +} + +export interface UpdateConnectionRequest { + connectionId: string; + note?: string; + removeNote?: boolean; +} + +export interface DeleteConnectionRequest { + connectionId: string; +} + +export interface GetForwardConnectionsForUrlParams + extends PaginatedSortedParams { + url: string; + connectionTypes?: ('CITATION' | 'REFERENCE' | 'RELATED' | 'INSPIRED_BY')[]; +} + +export interface GetBackwardConnectionsForUrlParams + extends PaginatedSortedParams { + url: string; + connectionTypes?: ('CITATION' | 'REFERENCE' | 'RELATED' | 'INSPIRED_BY')[]; +} + +// Search URLs request types +export interface SearchUrlsParams extends PaginatedSortedParams { + searchQuery: string; + urlType?: UrlType; +} diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 9bc822a6..6f81cb90 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -400,3 +400,52 @@ export interface GetCollectionContributorsResponse { users: ContributorUser[]; pagination: Pagination; } + +// Connection response types +export interface CreateConnectionResponse { + connectionId: string; +} + +export interface UpdateConnectionResponse { + connectionId: string; +} + +export interface DeleteConnectionResponse { + connectionId: string; +} + +export interface ConnectionForUrl { + connection: { + id: string; + type?: string; + note?: string; + createdAt: string; + updatedAt: string; + curator: User; + }; + url: UrlView; +} + +export interface ConnectionSorting { + sortBy: string; + sortOrder: 'asc' | 'desc'; +} + +export interface GetForwardConnectionsForUrlResponse { + connections: ConnectionForUrl[]; + pagination: Pagination; + sorting: ConnectionSorting; +} + +export interface GetBackwardConnectionsForUrlResponse { + connections: ConnectionForUrl[]; + pagination: Pagination; + sorting: ConnectionSorting; +} + +// Search URLs response types +export interface SearchUrlsResponse { + urls: UrlView[]; + pagination: Pagination; + sorting: CardSorting; +} diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index b092dcbb..59a5d417 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -2,6 +2,7 @@ import { QueryClient, CardClient, CollectionClient, + ConnectionClient, UserClient, FeedClient, NotificationClient, @@ -100,6 +101,20 @@ import type { GetFollowCountResponse, GetCollectionContributorsParams, GetCollectionContributorsResponse, + // Connection types + CreateConnectionRequest, + CreateConnectionResponse, + UpdateConnectionRequest, + UpdateConnectionResponse, + DeleteConnectionRequest, + DeleteConnectionResponse, + GetForwardConnectionsForUrlParams, + GetForwardConnectionsForUrlResponse, + GetBackwardConnectionsForUrlParams, + GetBackwardConnectionsForUrlResponse, + // Search types + SearchUrlsParams, + SearchUrlsResponse, } from '@semble/types'; // Main API Client class using composition @@ -107,6 +122,7 @@ export class ApiClient { private queryClient: QueryClient; private cardClient: CardClient; private collectionClient: CollectionClient; + private connectionClient: ConnectionClient; private userClient: UserClient; private feedClient: FeedClient; private notificationClient: NotificationClient; @@ -118,6 +134,7 @@ export class ApiClient { this.queryClient = new QueryClient(baseUrl, accessToken); this.cardClient = new CardClient(baseUrl, accessToken); this.collectionClient = new CollectionClient(baseUrl, accessToken); + this.connectionClient = new ConnectionClient(baseUrl, accessToken); this.userClient = new UserClient(baseUrl, accessToken); this.feedClient = new FeedClient(baseUrl, accessToken); this.notificationClient = new NotificationClient(baseUrl, accessToken); @@ -451,6 +468,42 @@ export class ApiClient { async markAllNotificationsAsRead(): Promise { return this.notificationClient.markAllNotificationsAsRead(); } + + // Connection operations + async createConnection( + request: CreateConnectionRequest, + ): Promise { + return this.connectionClient.createConnection(request); + } + + async updateConnection( + request: UpdateConnectionRequest, + ): Promise { + return this.connectionClient.updateConnection(request); + } + + async deleteConnection( + request: DeleteConnectionRequest, + ): Promise { + return this.connectionClient.deleteConnection(request); + } + + async getForwardConnectionsForUrl( + params: GetForwardConnectionsForUrlParams, + ): Promise { + return this.queryClient.getForwardConnectionsForUrl(params); + } + + async getBackwardConnectionsForUrl( + params: GetBackwardConnectionsForUrlParams, + ): Promise { + return this.queryClient.getBackwardConnectionsForUrl(params); + } + + // Search operations + async searchUrls(params: SearchUrlsParams): Promise { + return this.queryClient.searchUrls(params); + } } // Re-export types for convenience diff --git a/src/webapp/api-client/clients/ConnectionClient.ts b/src/webapp/api-client/clients/ConnectionClient.ts new file mode 100644 index 00000000..9db7673b --- /dev/null +++ b/src/webapp/api-client/clients/ConnectionClient.ts @@ -0,0 +1,43 @@ +import { BaseClient } from './BaseClient'; +import { + CreateConnectionRequest, + CreateConnectionResponse, + UpdateConnectionRequest, + UpdateConnectionResponse, + DeleteConnectionRequest, + DeleteConnectionResponse, +} from '@semble/types'; + +export class ConnectionClient extends BaseClient { + async createConnection( + request: CreateConnectionRequest, + ): Promise { + return this.request( + 'POST', + '/api/connections', + request, + ); + } + + async updateConnection( + request: UpdateConnectionRequest, + ): Promise { + return this.request( + 'PUT', + `/api/connections/${request.connectionId}`, + { + note: request.note, + removeNote: request.removeNote, + }, + ); + } + + async deleteConnection( + request: DeleteConnectionRequest, + ): Promise { + return this.request( + 'DELETE', + `/api/connections/${request.connectionId}`, + ); + } +} diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index 63e533bc..9f3bdb6f 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -48,6 +48,12 @@ import { GetFollowCountResponse, GetCollectionContributorsParams, GetCollectionContributorsResponse, + GetForwardConnectionsForUrlParams, + GetForwardConnectionsForUrlResponse, + GetBackwardConnectionsForUrlParams, + GetBackwardConnectionsForUrlResponse, + SearchUrlsParams, + SearchUrlsResponse, } from '@semble/types'; export class QueryClient extends BaseClient { @@ -468,4 +474,57 @@ export class QueryClient extends BaseClient { return this.request('GET', endpoint); } + + async getForwardConnectionsForUrl( + params: GetForwardConnectionsForUrlParams, + ): 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.connectionTypes) { + searchParams.set('connectionTypes', params.connectionTypes.join(',')); + } + + return this.request( + 'GET', + `/api/connections/forward?${searchParams}`, + ); + } + + async getBackwardConnectionsForUrl( + params: GetBackwardConnectionsForUrlParams, + ): 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.connectionTypes) { + searchParams.set('connectionTypes', params.connectionTypes.join(',')); + } + + return this.request( + 'GET', + `/api/connections/backward?${searchParams}`, + ); + } + + async searchUrls(params: SearchUrlsParams): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('searchQuery', params.searchQuery); + 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.urlType) searchParams.set('urlType', params.urlType); + + return this.request( + 'GET', + `/api/cards/search?${searchParams}`, + ); + } } diff --git a/src/webapp/api-client/clients/index.ts b/src/webapp/api-client/clients/index.ts index de19d1c9..0086ee8d 100644 --- a/src/webapp/api-client/clients/index.ts +++ b/src/webapp/api-client/clients/index.ts @@ -2,6 +2,7 @@ export * from './BaseClient'; export * from './QueryClient'; export * from './CardClient'; export * from './CollectionClient'; +export * from './ConnectionClient'; export * from './UserClient'; export * from './FeedClient'; export * from './NotificationClient';