diff --git a/src/modules/cards/infrastructure/http/controllers/GetCollectionFollowersController.ts b/src/modules/cards/infrastructure/http/controllers/GetCollectionFollowersController.ts new file mode 100644 index 00000000..59bf2552 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetCollectionFollowersController.ts @@ -0,0 +1,38 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetCollectionFollowersUseCase } from '../../../../user/application/useCases/queries/GetCollectionFollowersUseCase'; + +export class GetCollectionFollowersController extends Controller { + constructor( + private getCollectionFollowersUseCase: GetCollectionFollowersUseCase, + ) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { collectionId } = req.params; + const { page, limit } = req.query; + const callingUserId = (req as any).did; + + if (!collectionId) { + return this.fail(res, 'Collection ID is required'); + } + + const result = await this.getCollectionFollowersUseCase.execute({ + collectionId, + callingUserId, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit 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/cards/infrastructure/http/controllers/GetCollectionFollowersCountController.ts b/src/modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController.ts new file mode 100644 index 00000000..5c530713 --- /dev/null +++ b/src/modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController.ts @@ -0,0 +1,33 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetCollectionFollowersCountUseCase } from '../../../../user/application/useCases/queries/GetCollectionFollowersCountUseCase'; + +export class GetCollectionFollowersCountController extends Controller { + constructor( + private getCollectionFollowersCountUseCase: GetCollectionFollowersCountUseCase, + ) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { collectionId } = req.params; + + if (!collectionId) { + return this.fail(res, 'Collection ID is required'); + } + + const result = await this.getCollectionFollowersCountUseCase.execute({ + collectionId, + }); + + 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/cards/infrastructure/http/routes/collectionRoutes.ts b/src/modules/cards/infrastructure/http/routes/collectionRoutes.ts index 9af6ac4f..bb09ba7a 100644 --- a/src/modules/cards/infrastructure/http/routes/collectionRoutes.ts +++ b/src/modules/cards/infrastructure/http/routes/collectionRoutes.ts @@ -9,6 +9,8 @@ import { GetCollectionPageByAtUriController } from '../controllers/GetCollection import { GetCollectionsForUrlController } from '../controllers/GetCollectionsForUrlController'; import { SearchCollectionsController } from '../controllers/SearchCollectionsController'; import { GetOpenCollectionsWithContributorController } from '../controllers/GetOpenCollectionsWithContributorController'; +import { GetCollectionFollowersController } from '../controllers/GetCollectionFollowersController'; +import { GetCollectionFollowersCountController } from '../controllers/GetCollectionFollowersCountController'; import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; export function createCollectionRoutes( @@ -23,6 +25,8 @@ export function createCollectionRoutes( getCollectionsForUrlController: GetCollectionsForUrlController, searchCollectionsController: SearchCollectionsController, getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, + getCollectionFollowersController: GetCollectionFollowersController, + getCollectionFollowersCountController: GetCollectionFollowersCountController, ): Router { const router = Router(); @@ -61,6 +65,20 @@ export function createCollectionRoutes( (req, res) => getCollectionPageByAtUriController.execute(req, res), ); + // GET /api/collections/:collectionId/followers - Get collection followers + router.get( + '/:collectionId/followers', + authMiddleware.optionalAuth(), + (req, res) => getCollectionFollowersController.execute(req, res), + ); + + // GET /api/collections/:collectionId/followers/count - Get collection followers count + router.get( + '/:collectionId/followers/count', + authMiddleware.optionalAuth(), + (req, res) => getCollectionFollowersCountController.execute(req, res), + ); + // GET /api/collections/:collectionId - Get collection page router.get('/:collectionId', authMiddleware.optionalAuth(), (req, res) => getCollectionPageController.execute(req, res), diff --git a/src/modules/cards/infrastructure/http/routes/index.ts b/src/modules/cards/infrastructure/http/routes/index.ts index 6d5b40e5..2d4649f4 100644 --- a/src/modules/cards/infrastructure/http/routes/index.ts +++ b/src/modules/cards/infrastructure/http/routes/index.ts @@ -27,6 +27,8 @@ import { SearchCollectionsController } from '../controllers/SearchCollectionsCon import { GetOpenCollectionsWithContributorController } from '../controllers/GetOpenCollectionsWithContributorController'; import { GetCollectionPageByAtUriController } from '../controllers/GetCollectionPageByAtUriController'; import { GetCollectionsForUrlController } from '../controllers/GetCollectionsForUrlController'; +import { GetCollectionFollowersController } from '../controllers/GetCollectionFollowersController'; +import { GetCollectionFollowersCountController } from '../controllers/GetCollectionFollowersCountController'; export function createCardsModuleRoutes( authMiddleware: AuthMiddleware, @@ -57,6 +59,8 @@ export function createCardsModuleRoutes( getCollectionsForUrlController: GetCollectionsForUrlController, searchCollectionsController: SearchCollectionsController, getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, + getCollectionFollowersController: GetCollectionFollowersController, + getCollectionFollowersCountController: GetCollectionFollowersCountController, ): Router { const router = Router(); @@ -98,6 +102,8 @@ export function createCardsModuleRoutes( getCollectionsForUrlController, searchCollectionsController, getOpenCollectionsWithContributorController, + getCollectionFollowersController, + getCollectionFollowersCountController, ), ); diff --git a/src/modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase.ts b/src/modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase.ts new file mode 100644 index 00000000..20f33f4e --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase.ts @@ -0,0 +1,91 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; + +export interface GetCollectionFollowersCountQuery { + collectionId: string; // Collection UUID +} + +export interface GetCollectionFollowersCountResult { + count: number; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetCollectionFollowersCountUseCase + implements + UseCase< + GetCollectionFollowersCountQuery, + Result + > +{ + constructor( + private followsRepository: IFollowsRepository, + private collectionRepository: ICollectionRepository, + ) {} + + async execute( + query: GetCollectionFollowersCountQuery, + ): Promise> { + // Validate collection ID + const collectionIdResult = CollectionId.createFromString( + query.collectionId, + ); + if (collectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid collection ID: ${collectionIdResult.error.message}`, + ), + ); + } + + try { + // Verify collection exists + const collectionResult = await this.collectionRepository.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr()) { + return err( + new Error( + `Failed to fetch collection: ${collectionResult.error instanceof Error ? collectionResult.error.message : 'Unknown error'}`, + ), + ); + } + if (!collectionResult.value) { + return err(new ValidationError('Collection not found')); + } + + // Get count of users following this collection + const countResult = await this.followsRepository.getFollowersCount( + query.collectionId, + FollowTargetType.COLLECTION, + ); + + if (countResult.isErr()) { + return err( + new Error( + `Failed to retrieve collection followers count: ${countResult.error instanceof Error ? countResult.error.message : 'Unknown error'}`, + ), + ); + } + + return ok({ + count: countResult.value, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve collection followers count: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts b/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts new file mode 100644 index 00000000..7a483c28 --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts @@ -0,0 +1,162 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; +import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; +import { User } from '@semble/types'; + +export interface GetCollectionFollowersQuery { + collectionId: string; // Collection UUID + callingUserId?: string; + page?: number; + limit?: number; +} + +export interface GetCollectionFollowersResult { + users: User[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetCollectionFollowersUseCase + implements + UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private profileService: IProfileService, + private collectionRepository: ICollectionRepository, + ) {} + + async execute( + query: GetCollectionFollowersQuery, + ): Promise> { + // Set defaults + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + + // Validate collection ID + const collectionIdResult = CollectionId.createFromString( + query.collectionId, + ); + if (collectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid collection ID: ${collectionIdResult.error.message}`, + ), + ); + } + + try { + // Verify collection exists + const collectionResult = await this.collectionRepository.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr()) { + return err( + new Error( + `Failed to fetch collection: ${collectionResult.error instanceof Error ? collectionResult.error.message : 'Unknown error'}`, + ), + ); + } + if (!collectionResult.value) { + return err(new ValidationError('Collection not found')); + } + + // Get all followers (not paginated at repo level yet) + const followersResult = await this.followsRepository.getFollowers( + query.collectionId, + FollowTargetType.COLLECTION, + ); + + if (followersResult.isErr()) { + return err( + new Error( + `Failed to retrieve collection followers: ${followersResult.error instanceof Error ? followersResult.error.message : 'Unknown error'}`, + ), + ); + } + + const allFollows = followersResult.value; + const totalCount = allFollows.length; + + // Apply pagination manually + const offset = (page - 1) * limit; + const paginatedFollows = allFollows.slice(offset, offset + limit); + + // Extract unique follower IDs + const uniqueFollowerIds = Array.from( + new Set(paginatedFollows.map((follow) => follow.followerId.value)), + ); + + // Fetch profiles for all followers + const profilePromises = uniqueFollowerIds.map((followerId) => + this.profileService.getProfile(followerId, query.callingUserId), + ); + + const profileResults = await Promise.all(profilePromises); + + // Create a map of profiles + const profileMap = new Map(); + + for (let i = 0; i < uniqueFollowerIds.length; i++) { + const profileResult = profileResults[i]; + const followerId = uniqueFollowerIds[i]; + if (!profileResult || !followerId) { + return err(new Error('Missing profile result or follower ID')); + } + if (profileResult.isErr()) { + return err( + new Error( + `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, + ), + ); + } + const profile = profileResult.value; + profileMap.set(followerId, { + id: profile.id, + name: profile.name, + handle: profile.handle, + avatarUrl: profile.avatarUrl, + description: profile.bio, + }); + } + + // Build users array in the order of follows (chronological) + const users: User[] = paginatedFollows + .map((follow) => profileMap.get(follow.followerId.value)) + .filter((user): user is User => user !== undefined); + + return ok({ + users, + pagination: { + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + totalCount, + hasMore: page * limit < totalCount, + limit, + }, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve collection followers: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowersCountUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowersCountUseCase.ts new file mode 100644 index 00000000..1e6f6840 --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowersCountUseCase.ts @@ -0,0 +1,78 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; + +export interface GetFollowersCountQuery { + userId: string; // DID or handle +} + +export interface GetFollowersCountResult { + count: number; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowersCountUseCase + implements UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + ) {} + + async execute( + query: GetFollowersCountQuery, + ): Promise> { + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get count of users following this user + const countResult = await this.followsRepository.getFollowersCount( + didResult.value.value, + FollowTargetType.USER, + ); + + if (countResult.isErr()) { + return err( + new Error( + `Failed to retrieve followers count: ${countResult.error instanceof Error ? countResult.error.message : 'Unknown error'}`, + ), + ); + } + + return ok({ + count: countResult.value, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve followers count: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts new file mode 100644 index 00000000..0e631b9d --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts @@ -0,0 +1,150 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { User } from '@semble/types'; + +export interface GetFollowersQuery { + userId: string; // DID or handle + callingUserId?: string; + page?: number; + limit?: number; +} + +export interface GetFollowersResult { + users: User[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowersUseCase + implements UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + private profileService: IProfileService, + ) {} + + async execute(query: GetFollowersQuery): Promise> { + // Set defaults + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get all followers (not paginated at repo level yet) + const followersResult = await this.followsRepository.getFollowers( + didResult.value.value, + FollowTargetType.USER, + ); + + if (followersResult.isErr()) { + return err( + new Error( + `Failed to retrieve followers: ${followersResult.error instanceof Error ? followersResult.error.message : 'Unknown error'}`, + ), + ); + } + + const allFollows = followersResult.value; + const totalCount = allFollows.length; + + // Apply pagination manually + const offset = (page - 1) * limit; + const paginatedFollows = allFollows.slice(offset, offset + limit); + + // Extract unique follower IDs + const uniqueFollowerIds = Array.from( + new Set(paginatedFollows.map((follow) => follow.followerId.value)), + ); + + // Fetch profiles for all followers + const profilePromises = uniqueFollowerIds.map((followerId) => + this.profileService.getProfile(followerId, query.callingUserId), + ); + + const profileResults = await Promise.all(profilePromises); + + // Create a map of profiles + const profileMap = new Map(); + + for (let i = 0; i < uniqueFollowerIds.length; i++) { + const profileResult = profileResults[i]; + const followerId = uniqueFollowerIds[i]; + if (!profileResult || !followerId) { + return err(new Error('Missing profile result or follower ID')); + } + if (profileResult.isErr()) { + return err( + new Error( + `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, + ), + ); + } + const profile = profileResult.value; + profileMap.set(followerId, { + id: profile.id, + name: profile.name, + handle: profile.handle, + avatarUrl: profile.avatarUrl, + description: profile.bio, + }); + } + + // Build users array in the order of follows (chronological) + const users: User[] = paginatedFollows + .map((follow) => profileMap.get(follow.followerId.value)) + .filter((user): user is User => user !== undefined); + + return ok({ + users, + pagination: { + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + totalCount, + hasMore: page * limit < totalCount, + limit, + }, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve followers: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase.ts new file mode 100644 index 00000000..fc31d3f9 --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase.ts @@ -0,0 +1,82 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; + +export interface GetFollowingCollectionsCountQuery { + userId: string; // DID or handle +} + +export interface GetFollowingCollectionsCountResult { + count: number; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowingCollectionsCountUseCase + implements + UseCase< + GetFollowingCollectionsCountQuery, + Result + > +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + ) {} + + async execute( + query: GetFollowingCollectionsCountQuery, + ): Promise> { + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get count of collections that this user follows + const countResult = await this.followsRepository.getFollowingCount( + didResult.value.value, + FollowTargetType.COLLECTION, + ); + + if (countResult.isErr()) { + return err( + new Error( + `Failed to retrieve following collections count: ${countResult.error instanceof Error ? countResult.error.message : 'Unknown error'}`, + ), + ); + } + + return ok({ + count: countResult.value, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve following collections count: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts new file mode 100644 index 00000000..6c3644af --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts @@ -0,0 +1,201 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; +import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { Collection } from '@semble/types'; +import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; + +export interface GetFollowingCollectionsQuery { + userId: string; // DID or handle + callingUserId?: string; + page?: number; + limit?: number; +} + +export interface GetFollowingCollectionsResult { + collections: Collection[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowingCollectionsUseCase + implements + UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + private profileService: IProfileService, + private collectionRepository: ICollectionRepository, + ) {} + + async execute( + query: GetFollowingCollectionsQuery, + ): Promise> { + // Set defaults + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get paginated list of collections that this user follows + const followsResult = await this.followsRepository.getFollowing( + didResult.value.value, + FollowTargetType.COLLECTION, + { page, limit }, + ); + + if (followsResult.isErr()) { + return err( + new Error( + `Failed to retrieve following collections: ${followsResult.error instanceof Error ? followsResult.error.message : 'Unknown error'}`, + ), + ); + } + + const { follows, totalCount } = followsResult.value; + + // Extract unique collection IDs + const uniqueCollectionIds = Array.from( + new Set(follows.map((follow) => follow.targetId)), + ); + + // Fetch collections + const collectionPromises = uniqueCollectionIds.map( + async (collectionId) => { + const collectionIdResult = + CollectionId.createFromString(collectionId); + if (collectionIdResult.isErr()) { + return null; + } + return this.collectionRepository.findById(collectionIdResult.value); + }, + ); + + const collectionResults = await Promise.all(collectionPromises); + + // Fetch author profiles for all collections + const collectionsMap = new Map(); + const authorIds = new Set(); + + for (let i = 0; i < uniqueCollectionIds.length; i++) { + const collectionResult = collectionResults[i]; + const collectionId = uniqueCollectionIds[i]; + if (!collectionResult || !collectionId) { + continue; + } + if (collectionResult.isErr() || !collectionResult.value) { + console.error(`Failed to fetch collection ${collectionId}`); + continue; + } + const collection = collectionResult.value; + collectionsMap.set(collectionId, collection); + authorIds.add(collection.authorId.value); + } + + // Fetch all author profiles + const profilePromises = Array.from(authorIds).map((authorId) => + this.profileService.getProfile(authorId, query.callingUserId), + ); + + const profileResults = await Promise.all(profilePromises); + const profileMap = new Map(); + + const authorIdsArray = Array.from(authorIds); + for (let i = 0; i < authorIdsArray.length; i++) { + const profileResult = profileResults[i]; + const authorId = authorIdsArray[i]; + if (!profileResult || !authorId) { + continue; + } + if (profileResult.isErr()) { + console.error(`Failed to fetch profile for ${authorId}`); + continue; + } + const profile = profileResult.value; + profileMap.set(authorId, { + id: profile.id, + name: profile.name, + handle: profile.handle, + avatarUrl: profile.avatarUrl, + description: profile.bio, + }); + } + + // Build collections array in the order of follows (chronological) + const collections = follows + .map((follow) => { + const collection = collectionsMap.get(follow.targetId); + if (!collection) { + return null; + } + const author = profileMap.get(collection.authorId.value); + if (!author) { + return null; + } + return { + id: collection.id.value, + uri: collection.publishedRecordId?.getValue().uri, + name: collection.name, + author, + description: collection.description, + accessType: collection.accessType.value, + cardCount: collection.cardCount, + createdAt: collection.createdAt.toISOString(), + updatedAt: collection.updatedAt.toISOString(), + } as Collection; + }) + .filter((collection): collection is Collection => collection !== null); + + return ok({ + collections, + pagination: { + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + totalCount, + hasMore: page * limit < totalCount, + limit, + }, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve following collections: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowingCountUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingCountUseCase.ts new file mode 100644 index 00000000..1eb794cd --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowingCountUseCase.ts @@ -0,0 +1,78 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; + +export interface GetFollowingCountQuery { + userId: string; // DID or handle +} + +export interface GetFollowingCountResult { + count: number; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowingCountUseCase + implements UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + ) {} + + async execute( + query: GetFollowingCountQuery, + ): Promise> { + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get count of users that this user follows + const countResult = await this.followsRepository.getFollowingCount( + didResult.value.value, + FollowTargetType.USER, + ); + + if (countResult.isErr()) { + return err( + new Error( + `Failed to retrieve following count: ${countResult.error instanceof Error ? countResult.error.message : 'Unknown error'}`, + ), + ); + } + + return ok({ + count: countResult.value, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve following count: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts new file mode 100644 index 00000000..8aa686b4 --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts @@ -0,0 +1,148 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; +import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; +import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { User } from '@semble/types'; + +export interface GetFollowingUsersQuery { + userId: string; // DID or handle + callingUserId?: string; + page?: number; + limit?: number; +} + +export interface GetFollowingUsersResult { + users: User[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetFollowingUsersUseCase + implements UseCase> +{ + constructor( + private followsRepository: IFollowsRepository, + private identityResolver: IIdentityResolutionService, + private profileService: IProfileService, + ) {} + + async execute( + query: GetFollowingUsersQuery, + ): Promise> { + // Set defaults + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + + // Parse and validate user identifier + const identifierResult = DIDOrHandle.create(query.userId); + if (identifierResult.isErr()) { + return err(new ValidationError('Invalid user identifier')); + } + + // Resolve to DID + const didResult = await this.identityResolver.resolveToDID( + identifierResult.value, + ); + if (didResult.isErr()) { + return err( + new ValidationError( + `Could not resolve user identifier: ${didResult.error.message}`, + ), + ); + } + + try { + // Get paginated list of users that this user follows + const followsResult = await this.followsRepository.getFollowing( + didResult.value.value, + FollowTargetType.USER, + { page, limit }, + ); + + if (followsResult.isErr()) { + return err( + new Error( + `Failed to retrieve following users: ${followsResult.error instanceof Error ? followsResult.error.message : 'Unknown error'}`, + ), + ); + } + + const { follows, totalCount } = followsResult.value; + + // Extract unique target IDs (the users being followed) + const uniqueTargetIds = Array.from( + new Set(follows.map((follow) => follow.targetId)), + ); + + // Fetch profiles for all followed users + const profilePromises = uniqueTargetIds.map((targetId) => + this.profileService.getProfile(targetId, query.callingUserId), + ); + + const profileResults = await Promise.all(profilePromises); + + // Create a map of profiles + const profileMap = new Map(); + + for (let i = 0; i < uniqueTargetIds.length; i++) { + const profileResult = profileResults[i]; + const targetId = uniqueTargetIds[i]; + if (!profileResult || !targetId) { + return err(new Error('Missing profile result or target ID')); + } + if (profileResult.isErr()) { + return err( + new Error( + `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, + ), + ); + } + const profile = profileResult.value; + profileMap.set(targetId, { + id: profile.id, + name: profile.name, + handle: profile.handle, + avatarUrl: profile.avatarUrl, + description: profile.bio, + }); + } + + // Build users array in the order of follows (chronological) + const users: User[] = follows + .map((follow) => profileMap.get(follow.targetId)) + .filter((user): user is User => user !== undefined); + + return ok({ + users, + pagination: { + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + totalCount, + hasMore: page * limit < totalCount, + limit, + }, + }); + } catch (error) { + return err( + new Error( + `Failed to retrieve following users: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/user/domain/repositories/IFollowsRepository.ts b/src/modules/user/domain/repositories/IFollowsRepository.ts index 37333806..192887cf 100644 --- a/src/modules/user/domain/repositories/IFollowsRepository.ts +++ b/src/modules/user/domain/repositories/IFollowsRepository.ts @@ -78,4 +78,54 @@ export interface IFollowsRepository { targetId: string, targetType: FollowTargetType, ): Promise>; + + /** + * Get paginated list of entities that a user follows. + * + * @param followerId - DID of the follower + * @param targetType - Type of entities to get (USER or COLLECTION) + * @param options - Pagination options + * @returns Paginated list of Follow records and total count + * + * Example: + * getFollowing('did:plc:alice123', FollowTargetType.USER, { page: 1, limit: 20 }) + * → { follows: [...], totalCount: 45 } + */ + getFollowing( + followerId: string, + targetType: FollowTargetType, + options: { page: number; limit: number }, + ): Promise>; + + /** + * Get count of entities that a user follows. + * + * @param followerId - DID of the follower + * @param targetType - Optional type filter (USER or COLLECTION) + * @returns Count of followed entities + * + * Example: + * getFollowingCount('did:plc:alice123', FollowTargetType.USER) → 45 + * getFollowingCount('did:plc:alice123') → 60 (all types) + */ + getFollowingCount( + followerId: string, + targetType?: FollowTargetType, + ): Promise>; + + /** + * Get count of followers for a specific entity. + * + * @param targetId - ID of the entity (user DID or collection UUID) + * @param targetType - Type of entity + * @returns Count of followers + * + * Example: + * getFollowersCount('did:plc:alice123', FollowTargetType.USER) → 150 + * getFollowersCount('uuid-collection-1', FollowTargetType.COLLECTION) → 25 + */ + getFollowersCount( + targetId: string, + targetType: FollowTargetType, + ): Promise>; } diff --git a/src/modules/user/infrastructure/http/controllers/GetFollowersController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowersController.ts new file mode 100644 index 00000000..69d81bb3 --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowersController.ts @@ -0,0 +1,36 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowersUseCase } from '../../../application/useCases/queries/GetFollowersUseCase'; + +export class GetFollowersController extends Controller { + constructor(private getFollowersUseCase: GetFollowersUseCase) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + const { page, limit } = req.query; + const callingUserId = (req as any).did; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowersUseCase.execute({ + userId: identifier, + callingUserId, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit 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/user/infrastructure/http/controllers/GetFollowersCountController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowersCountController.ts new file mode 100644 index 00000000..5a6ddbd7 --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowersCountController.ts @@ -0,0 +1,31 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowersCountUseCase } from '../../../application/useCases/queries/GetFollowersCountUseCase'; + +export class GetFollowersCountController extends Controller { + constructor(private getFollowersCountUseCase: GetFollowersCountUseCase) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowersCountUseCase.execute({ + userId: identifier, + }); + + 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/user/infrastructure/http/controllers/GetFollowingCollectionsController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowingCollectionsController.ts new file mode 100644 index 00000000..12ce50cb --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowingCollectionsController.ts @@ -0,0 +1,38 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowingCollectionsUseCase } from '../../../application/useCases/queries/GetFollowingCollectionsUseCase'; + +export class GetFollowingCollectionsController extends Controller { + constructor( + private getFollowingCollectionsUseCase: GetFollowingCollectionsUseCase, + ) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + const { page, limit } = req.query; + const callingUserId = (req as any).did; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowingCollectionsUseCase.execute({ + userId: identifier, + callingUserId, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit 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/user/infrastructure/http/controllers/GetFollowingCollectionsCountController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowingCollectionsCountController.ts new file mode 100644 index 00000000..fa483f1c --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowingCollectionsCountController.ts @@ -0,0 +1,33 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowingCollectionsCountUseCase } from '../../../application/useCases/queries/GetFollowingCollectionsCountUseCase'; + +export class GetFollowingCollectionsCountController extends Controller { + constructor( + private getFollowingCollectionsCountUseCase: GetFollowingCollectionsCountUseCase, + ) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowingCollectionsCountUseCase.execute({ + userId: identifier, + }); + + 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/user/infrastructure/http/controllers/GetFollowingCountController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowingCountController.ts new file mode 100644 index 00000000..39c46dd3 --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowingCountController.ts @@ -0,0 +1,31 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowingCountUseCase } from '../../../application/useCases/queries/GetFollowingCountUseCase'; + +export class GetFollowingCountController extends Controller { + constructor(private getFollowingCountUseCase: GetFollowingCountUseCase) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowingCountUseCase.execute({ + userId: identifier, + }); + + 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/user/infrastructure/http/controllers/GetFollowingUsersController.ts b/src/modules/user/infrastructure/http/controllers/GetFollowingUsersController.ts new file mode 100644 index 00000000..867cb653 --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetFollowingUsersController.ts @@ -0,0 +1,36 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetFollowingUsersUseCase } from '../../../application/useCases/queries/GetFollowingUsersUseCase'; + +export class GetFollowingUsersController extends Controller { + constructor(private getFollowingUsersUseCase: GetFollowingUsersUseCase) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + const { identifier } = req.params; + const { page, limit } = req.query; + const callingUserId = (req as any).did; + + if (!identifier) { + return this.fail(res, 'Identifier (DID or handle) is required'); + } + + const result = await this.getFollowingUsersUseCase.execute({ + userId: identifier, + callingUserId, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit 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/user/infrastructure/http/routes/userRoutes.ts b/src/modules/user/infrastructure/http/routes/userRoutes.ts index a29dd841..a9cbf24c 100644 --- a/src/modules/user/infrastructure/http/routes/userRoutes.ts +++ b/src/modules/user/infrastructure/http/routes/userRoutes.ts @@ -10,6 +10,12 @@ import { LogoutController } from '../controllers/LogoutController'; import { GenerateExtensionTokensController } from '../controllers/GenerateExtensionTokensController'; import { FollowTargetController } from '../controllers/FollowTargetController'; import { UnfollowTargetController } from '../controllers/UnfollowTargetController'; +import { GetFollowingUsersController } from '../controllers/GetFollowingUsersController'; +import { GetFollowersController } from '../controllers/GetFollowersController'; +import { GetFollowingCollectionsController } from '../controllers/GetFollowingCollectionsController'; +import { GetFollowingCountController } from '../controllers/GetFollowingCountController'; +import { GetFollowersCountController } from '../controllers/GetFollowersCountController'; +import { GetFollowingCollectionsCountController } from '../controllers/GetFollowingCollectionsCountController'; export const createUserRoutes = ( router: Router, @@ -24,6 +30,12 @@ export const createUserRoutes = ( generateExtensionTokensController: GenerateExtensionTokensController, followTargetController: FollowTargetController, unfollowTargetController: UnfollowTargetController, + getFollowingUsersController: GetFollowingUsersController, + getFollowersController: GetFollowersController, + getFollowingCollectionsController: GetFollowingCollectionsController, + getFollowingCountController: GetFollowingCountController, + getFollowersCountController: GetFollowersCountController, + getFollowingCollectionsCountController: GetFollowingCollectionsCountController, ) => { // Public routes router.get('/login', (req, res) => @@ -67,5 +79,43 @@ export const createUserRoutes = ( (req, res) => unfollowTargetController.execute(req, res), ); + // Following/Followers query routes (public with optional auth) + router.get( + '/:identifier/following', + authMiddleware.optionalAuth(), + (req, res) => getFollowingUsersController.execute(req, res), + ); + + router.get( + '/:identifier/followers', + authMiddleware.optionalAuth(), + (req, res) => getFollowersController.execute(req, res), + ); + + router.get( + '/:identifier/following-collections', + authMiddleware.optionalAuth(), + (req, res) => getFollowingCollectionsController.execute(req, res), + ); + + // Following/Followers count routes (public) + router.get( + '/:identifier/following/count', + authMiddleware.optionalAuth(), + (req, res) => getFollowingCountController.execute(req, res), + ); + + router.get( + '/:identifier/followers/count', + authMiddleware.optionalAuth(), + (req, res) => getFollowersCountController.execute(req, res), + ); + + router.get( + '/:identifier/following-collections/count', + authMiddleware.optionalAuth(), + (req, res) => getFollowingCollectionsCountController.execute(req, res), + ); + return router; }; diff --git a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts index 08ebe6f5..8c5c30bb 100644 --- a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts +++ b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts @@ -291,4 +291,130 @@ export class DrizzleFollowsRepository implements IFollowsRepository { return err(error as Error); } } + + async getFollowing( + followerId: string, + targetType: FollowTargetType, + options: { page: number; limit: number }, + ): Promise> { + try { + const offset = (options.page - 1) * options.limit; + + // Get total count + const countResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.targetType, targetType.value), + ), + ); + + const totalCount = countResult[0]?.count ?? 0; + + // Get paginated results + const results = await this.db + .select() + .from(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.targetType, targetType.value), + ), + ) + .orderBy(sql`${follows.createdAt} DESC`) + .limit(options.limit) + .offset(offset); + + const followEntities: Follow[] = []; + + for (const row of results) { + const followerDIDResult = DID.create(row.followerId); + if (followerDIDResult.isErr()) { + console.error( + `Invalid follower DID: ${row.followerId}`, + followerDIDResult.error, + ); + continue; + } + + const targetTypeResult = FollowTargetType.create( + row.targetType as FollowTargetTypeEnum, + ); + if (targetTypeResult.isErr()) { + console.error( + `Invalid target type: ${row.targetType}`, + targetTypeResult.error, + ); + continue; + } + + const followResult = Follow.create( + { + followerId: followerDIDResult.value, + targetId: row.targetId, + targetType: targetTypeResult.value, + createdAt: row.createdAt, + }, + new UniqueEntityID( + `${row.followerId}:${row.targetId}:${row.targetType}`, + ), + ); + + if (followResult.isOk()) { + followEntities.push(followResult.value); + } else { + console.error('Failed to create Follow entity:', followResult.error); + } + } + + return ok({ follows: followEntities, totalCount }); + } catch (error: any) { + return err(error); + } + } + + async getFollowingCount( + followerId: string, + targetType?: FollowTargetType, + ): Promise> { + try { + const conditions = [eq(follows.followerId, followerId)]; + + if (targetType) { + conditions.push(eq(follows.targetType, targetType.value)); + } + + const result = await this.db + .select({ count: sql`count(*)::int` }) + .from(follows) + .where(and(...conditions)); + + return ok(result[0]?.count ?? 0); + } catch (error: any) { + return err(error); + } + } + + async getFollowersCount( + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const result = await this.db + .select({ count: sql`count(*)::int` }) + .from(follows) + .where( + and( + eq(follows.targetId, targetId), + eq(follows.targetType, targetType.value), + ), + ); + + return ok(result[0]?.count ?? 0); + } catch (error: any) { + return err(error); + } + } } diff --git a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts index 349de3e4..e9e9fe21 100644 --- a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts +++ b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts @@ -105,6 +105,79 @@ export class InMemoryFollowsRepository implements IFollowsRepository { } } + async getFollowing( + followerId: string, + targetType: FollowTargetType, + options: { page: number; limit: number }, + ): Promise> { + try { + const allFollows: Follow[] = []; + + for (const follow of this.follows.values()) { + if ( + follow.followerId.value === followerId && + follow.targetType.equals(targetType) + ) { + allFollows.push(follow); + } + } + + // Sort by creation date descending (most recent first) + allFollows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const totalCount = allFollows.length; + const offset = (options.page - 1) * options.limit; + const follows = allFollows.slice(offset, offset + options.limit); + + return ok({ follows, totalCount }); + } catch (error: any) { + return err(error); + } + } + + async getFollowingCount( + followerId: string, + targetType?: FollowTargetType, + ): Promise> { + try { + let count = 0; + + for (const follow of this.follows.values()) { + if (follow.followerId.value === followerId) { + if (!targetType || follow.targetType.equals(targetType)) { + count++; + } + } + } + + return ok(count); + } catch (error: any) { + return err(error); + } + } + + async getFollowersCount( + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + let count = 0; + + for (const follow of this.follows.values()) { + if ( + follow.targetId === targetId && + follow.targetType.equals(targetType) + ) { + count++; + } + } + + return ok(count); + } catch (error: any) { + return err(error); + } + } + // Helper method for testing clear(): void { this.follows.clear(); diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index bc9e0822..076acd3d 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -85,6 +85,12 @@ export const createExpressApp = ( controllers.generateExtensionTokensController, controllers.followTargetController, controllers.unfollowTargetController, + controllers.getFollowingUsersController, + controllers.getFollowersController, + controllers.getFollowingCollectionsController, + controllers.getFollowingCountController, + controllers.getFollowersCountController, + controllers.getFollowingCollectionsCountController, ); createAtprotoRoutes(atprotoRouter, services.nodeOauthClient); @@ -118,6 +124,8 @@ export const createExpressApp = ( controllers.getCollectionsForUrlController, controllers.searchCollectionsController, controllers.getOpenCollectionsWithContributorController, + controllers.getCollectionFollowersController, + controllers.getCollectionFollowersCountController, ); const feedRouter = createFeedRoutes( diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index 5d899f64..e7f353cf 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -46,6 +46,14 @@ import { MarkNotificationsAsReadController } from '../../../../modules/notificat import { MarkAllNotificationsAsReadController } from '../../../../modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController'; import { FollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/FollowTargetController'; import { UnfollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/UnfollowTargetController'; +import { GetFollowingUsersController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingUsersController'; +import { GetFollowersController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowersController'; +import { GetFollowingCollectionsController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCollectionsController'; +import { GetFollowingCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCountController'; +import { GetFollowersCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowersCountController'; +import { GetFollowingCollectionsCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCollectionsCountController'; +import { GetCollectionFollowersController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersController'; +import { GetCollectionFollowersCountController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController'; import { CookieService } from '../services/CookieService'; export interface Controllers { @@ -60,6 +68,12 @@ export interface Controllers { generateExtensionTokensController: GenerateExtensionTokensController; followTargetController: FollowTargetController; unfollowTargetController: UnfollowTargetController; + getFollowingUsersController: GetFollowingUsersController; + getFollowersController: GetFollowersController; + getFollowingCollectionsController: GetFollowingCollectionsController; + getFollowingCountController: GetFollowingCountController; + getFollowersCountController: GetFollowersCountController; + getFollowingCollectionsCountController: GetFollowingCollectionsCountController; // Card controllers addUrlToLibraryController: AddUrlToLibraryController; addCardToLibraryController: AddCardToLibraryController; @@ -86,6 +100,8 @@ export interface Controllers { getLibrariesForUrlController: GetLibrariesForUrlController; getCollectionsForUrlController: GetCollectionsForUrlController; getNoteCardsForUrlController: GetNoteCardsForUrlController; + getCollectionFollowersController: GetCollectionFollowersController; + getCollectionFollowersCountController: GetCollectionFollowersCountController; // Feed controllers getGlobalFeedController: GetGlobalFeedController; getGemActivityFeedController: GetGemActivityFeedController; @@ -141,6 +157,25 @@ export class ControllerFactory { unfollowTargetController: new UnfollowTargetController( useCases.unfollowTargetUseCase, ), + getFollowingUsersController: new GetFollowingUsersController( + useCases.getFollowingUsersUseCase, + ), + getFollowersController: new GetFollowersController( + useCases.getFollowersUseCase, + ), + getFollowingCollectionsController: new GetFollowingCollectionsController( + useCases.getFollowingCollectionsUseCase, + ), + getFollowingCountController: new GetFollowingCountController( + useCases.getFollowingCountUseCase, + ), + getFollowersCountController: new GetFollowersCountController( + useCases.getFollowersCountUseCase, + ), + getFollowingCollectionsCountController: + new GetFollowingCollectionsCountController( + useCases.getFollowingCollectionsCountUseCase, + ), // Card controllers addUrlToLibraryController: new AddUrlToLibraryController( @@ -223,6 +258,13 @@ export class ControllerFactory { getNoteCardsForUrlController: new GetNoteCardsForUrlController( useCases.getNoteCardsForUrlUseCase, ), + getCollectionFollowersController: new GetCollectionFollowersController( + useCases.getCollectionFollowersUseCase, + ), + getCollectionFollowersCountController: + new GetCollectionFollowersCountController( + useCases.getCollectionFollowersCountUseCase, + ), // Feed controllers getGlobalFeedController: new GetGlobalFeedController( diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index b52178e3..67624d27 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -55,6 +55,14 @@ import { CreateNotificationUseCase } from '../../../../modules/notifications/app import { SyncAccountDataUseCase } from '../../../../modules/sync/application/useCases/SyncAccountDataUseCase'; import { FollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/FollowTargetUseCase'; import { UnfollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/UnfollowTargetUseCase'; +import { GetFollowingUsersUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingUsersUseCase'; +import { GetFollowersUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowersUseCase'; +import { GetFollowingCollectionsUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCollectionsUseCase'; +import { GetCollectionFollowersUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersUseCase'; +import { GetFollowingCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCountUseCase'; +import { GetFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowersCountUseCase'; +import { GetFollowingCollectionsCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase'; +import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase'; export interface WorkerUseCases { addActivityToFeedUseCase: AddActivityToFeedUseCase; @@ -88,6 +96,14 @@ export interface UseCases { generateExtensionTokensUseCase: GenerateExtensionTokensUseCase; followTargetUseCase: FollowTargetUseCase; unfollowTargetUseCase: UnfollowTargetUseCase; + getFollowingUsersUseCase: GetFollowingUsersUseCase; + getFollowersUseCase: GetFollowersUseCase; + getFollowingCollectionsUseCase: GetFollowingCollectionsUseCase; + getCollectionFollowersUseCase: GetCollectionFollowersUseCase; + getFollowingCountUseCase: GetFollowingCountUseCase; + getFollowersCountUseCase: GetFollowersCountUseCase; + getFollowingCollectionsCountUseCase: GetFollowingCollectionsCountUseCase; + getCollectionFollowersCountUseCase: GetCollectionFollowersCountUseCase; // Card use cases addUrlToLibraryUseCase: AddUrlToLibraryUseCase; addCardToLibraryUseCase: AddCardToLibraryUseCase; @@ -185,6 +201,45 @@ export class UseCaseFactory { services.followPublisher, services.eventPublisher, ), + getFollowingUsersUseCase: new GetFollowingUsersUseCase( + repositories.followsRepository, + services.identityResolutionService, + services.profileService, + ), + getFollowersUseCase: new GetFollowersUseCase( + repositories.followsRepository, + services.identityResolutionService, + services.profileService, + ), + getFollowingCollectionsUseCase: new GetFollowingCollectionsUseCase( + repositories.followsRepository, + services.identityResolutionService, + services.profileService, + repositories.collectionRepository, + ), + getCollectionFollowersUseCase: new GetCollectionFollowersUseCase( + repositories.followsRepository, + services.profileService, + repositories.collectionRepository, + ), + getFollowingCountUseCase: new GetFollowingCountUseCase( + repositories.followsRepository, + services.identityResolutionService, + ), + getFollowersCountUseCase: new GetFollowersCountUseCase( + repositories.followsRepository, + services.identityResolutionService, + ), + getFollowingCollectionsCountUseCase: + new GetFollowingCollectionsCountUseCase( + repositories.followsRepository, + services.identityResolutionService, + ), + getCollectionFollowersCountUseCase: + new GetCollectionFollowersCountUseCase( + repositories.followsRepository, + repositories.collectionRepository, + ), // Card use cases addUrlToLibraryUseCase: new AddUrlToLibraryUseCase( diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index c28e6f2d..524ba308 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -258,3 +258,37 @@ export interface UnfollowTargetRequest { targetId: string; targetType: 'USER' | 'COLLECTION'; } + +// Follow query request types +export interface GetFollowingUsersParams extends PaginationParams { + identifier: string; // Can be DID or handle +} + +export interface GetFollowersParams extends PaginationParams { + identifier: string; // Can be DID or handle +} + +export interface GetFollowingCollectionsParams extends PaginationParams { + identifier: string; // Can be DID or handle +} + +export interface GetCollectionFollowersParams extends PaginationParams { + collectionId: string; // Collection UUID +} + +// Follow count request types +export interface GetFollowingCountParams { + identifier: string; // Can be DID or handle +} + +export interface GetFollowersCountParams { + identifier: string; // Can be DID or handle +} + +export interface GetFollowingCollectionsCountParams { + identifier: string; // Can be DID or handle +} + +export interface GetCollectionFollowersCountParams { + collectionId: string; // Collection UUID +} diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 88b2fa93..8c7d1ad5 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -371,3 +371,29 @@ export interface MarkAllNotificationsAsReadResponse { export interface FollowTargetResponse { followId: string; } + +// Follow query response types +export interface GetFollowingUsersResponse { + users: User[]; + pagination: Pagination; +} + +export interface GetFollowersResponse { + users: User[]; + pagination: Pagination; +} + +export interface GetFollowingCollectionsResponse { + collections: Collection[]; + pagination: Pagination; +} + +export interface GetCollectionFollowersResponse { + users: User[]; + pagination: Pagination; +} + +// Follow count response types +export interface GetFollowCountResponse { + count: number; +} diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 8f57a1b9..dd86e84d 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -85,6 +85,19 @@ import type { GetOpenCollectionsWithContributorParams, FollowTargetRequest, FollowTargetResponse, + GetFollowingUsersParams, + GetFollowingUsersResponse, + GetFollowersParams, + GetFollowersResponse, + GetFollowingCollectionsParams, + GetFollowingCollectionsResponse, + GetCollectionFollowersParams, + GetCollectionFollowersResponse, + GetFollowingCountParams, + GetFollowersCountParams, + GetFollowingCollectionsCountParams, + GetCollectionFollowersCountParams, + GetFollowCountResponse, } from '@semble/types'; // Main API Client class using composition @@ -226,6 +239,55 @@ export class ApiClient { return this.queryClient.searchLeafletDocs(params); } + // Follow query operations - delegate to QueryClient + async getFollowingUsers( + params: GetFollowingUsersParams, + ): Promise { + return this.queryClient.getFollowingUsers(params); + } + + async getFollowers( + params: GetFollowersParams, + ): Promise { + return this.queryClient.getFollowers(params); + } + + async getFollowingCollections( + params: GetFollowingCollectionsParams, + ): Promise { + return this.queryClient.getFollowingCollections(params); + } + + async getCollectionFollowers( + params: GetCollectionFollowersParams, + ): Promise { + return this.queryClient.getCollectionFollowers(params); + } + + async getFollowingCount( + params: GetFollowingCountParams, + ): Promise { + return this.queryClient.getFollowingCount(params); + } + + async getFollowersCount( + params: GetFollowersCountParams, + ): Promise { + return this.queryClient.getFollowersCount(params); + } + + async getFollowingCollectionsCount( + params: GetFollowingCollectionsCountParams, + ): Promise { + return this.queryClient.getFollowingCollectionsCount(params); + } + + async getCollectionFollowersCount( + params: GetCollectionFollowersCountParams, + ): Promise { + return this.queryClient.getCollectionFollowersCount(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 5a88f919..76b29159 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -33,6 +33,19 @@ import { SearchLeafletDocsForUrlParams, SearchLeafletDocsForUrlResponse, GetOpenCollectionsWithContributorParams, + GetFollowingUsersParams, + GetFollowingUsersResponse, + GetFollowersParams, + GetFollowersResponse, + GetFollowingCollectionsParams, + GetFollowingCollectionsResponse, + GetCollectionFollowersParams, + GetCollectionFollowersResponse, + GetFollowingCountParams, + GetFollowersCountParams, + GetFollowingCollectionsCountParams, + GetCollectionFollowersCountParams, + GetFollowCountResponse, } from '@semble/types'; export class QueryClient extends BaseClient { @@ -341,4 +354,101 @@ export class QueryClient extends BaseClient { return this.request('GET', endpoint); } + + // Follow query methods + async getFollowingUsers( + params: GetFollowingUsersParams, + ): Promise { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', params.page.toString()); + if (params.limit) searchParams.set('limit', params.limit.toString()); + + const queryString = searchParams.toString(); + const endpoint = queryString + ? `/api/users/${params.identifier}/following?${queryString}` + : `/api/users/${params.identifier}/following`; + + return this.request('GET', endpoint); + } + + async getFollowers( + params: GetFollowersParams, + ): Promise { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', params.page.toString()); + if (params.limit) searchParams.set('limit', params.limit.toString()); + + const queryString = searchParams.toString(); + const endpoint = queryString + ? `/api/users/${params.identifier}/followers?${queryString}` + : `/api/users/${params.identifier}/followers`; + + return this.request('GET', endpoint); + } + + async getFollowingCollections( + params: GetFollowingCollectionsParams, + ): Promise { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', params.page.toString()); + if (params.limit) searchParams.set('limit', params.limit.toString()); + + const queryString = searchParams.toString(); + const endpoint = queryString + ? `/api/users/${params.identifier}/following-collections?${queryString}` + : `/api/users/${params.identifier}/following-collections`; + + return this.request('GET', endpoint); + } + + async getCollectionFollowers( + params: GetCollectionFollowersParams, + ): Promise { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', params.page.toString()); + if (params.limit) searchParams.set('limit', params.limit.toString()); + + const queryString = searchParams.toString(); + const endpoint = queryString + ? `/api/collections/${params.collectionId}/followers?${queryString}` + : `/api/collections/${params.collectionId}/followers`; + + return this.request('GET', endpoint); + } + + async getFollowingCount( + params: GetFollowingCountParams, + ): Promise { + return this.request( + 'GET', + `/api/users/${params.identifier}/following/count`, + ); + } + + async getFollowersCount( + params: GetFollowersCountParams, + ): Promise { + return this.request( + 'GET', + `/api/users/${params.identifier}/followers/count`, + ); + } + + async getFollowingCollectionsCount( + params: GetFollowingCollectionsCountParams, + ): Promise { + return this.request( + 'GET', + `/api/users/${params.identifier}/following-collections/count`, + ); + } + + async getCollectionFollowersCount( + params: GetCollectionFollowersCountParams, + ): Promise { + return this.request( + 'GET', + `/api/collections/${params.collectionId}/followers/count`, + ); + } }