diff --git a/src/modules/cards/application/mappers/ProfileMapper.ts b/src/modules/cards/application/mappers/ProfileMapper.ts index f05fbd5d..774b29ee 100644 --- a/src/modules/cards/application/mappers/ProfileMapper.ts +++ b/src/modules/cards/application/mappers/ProfileMapper.ts @@ -24,6 +24,8 @@ export class ProfileMapper { followerCount: profile.followerCount, followingCount: profile.followingCount, followedCollectionsCount: profile.followedCollectionsCount, + urlCardCount: profile.urlCardCount, + collectionCount: profile.collectionCount, }; } diff --git a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts index 82275b36..296da1f0 100644 --- a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts @@ -5,10 +5,13 @@ import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; import { ProfileMapper } from '../../mappers/ProfileMapper'; import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { ICardQueryRepository } from '../../../domain/ICardQueryRepository'; +import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; export interface GetMyProfileQuery { userId: string; callerDid?: string; + includeStats?: boolean; } export interface GetMyProfileResult { @@ -34,6 +37,8 @@ export class GetProfileUseCase private profileService: IProfileService, private identityResolver: IIdentityResolutionService, private followsRepository: IFollowsRepository, + private cardQueryRepository: ICardQueryRepository, + private collectionQueryRepository: ICollectionQueryRepository, ) {} async execute(query: GetMyProfileQuery): Promise> { @@ -104,12 +109,29 @@ export class GetProfileUseCase const counts = countsResult.value; + // Conditionally fetch card and collection stats + let profileStats = {}; + if (query.includeStats) { + const [cardStats, collectionCount] = await Promise.all([ + this.cardQueryRepository.getProfileCardStats(didResult.value.value), + this.collectionQueryRepository.getProfileCollectionCount( + didResult.value.value, + ), + ]); + + profileStats = { + urlCardCount: cardStats.urlCardCount, + collectionCount: collectionCount, + }; + } + // Merge counts into profile const profileWithCounts = { ...profile, followerCount: counts.followerCount, followingCount: counts.followingCount, followedCollectionsCount: counts.followedCollectionsCount, + ...profileStats, }; // Map profile using ProfileMapper diff --git a/src/modules/cards/domain/ICardQueryRepository.ts b/src/modules/cards/domain/ICardQueryRepository.ts index f8de2ea1..78db4913 100644 --- a/src/modules/cards/domain/ICardQueryRepository.ts +++ b/src/modules/cards/domain/ICardQueryRepository.ts @@ -224,4 +224,12 @@ export interface ICardQueryRepository { libraryCount: number; noteCount: number; }>; + + /** + * Get aggregate card statistics for a user profile + * Used for efficient counting of URL cards authored by user + */ + getProfileCardStats(authorId: string): Promise<{ + urlCardCount: number; + }>; } diff --git a/src/modules/cards/domain/ICollectionQueryRepository.ts b/src/modules/cards/domain/ICollectionQueryRepository.ts index 06dc9282..4e6dbfed 100644 --- a/src/modules/cards/domain/ICollectionQueryRepository.ts +++ b/src/modules/cards/domain/ICollectionQueryRepository.ts @@ -134,4 +134,10 @@ export interface ICollectionQueryRepository { * Used for efficient counting without fetching full data */ getCollectionCountForUrl(url: string): Promise; + + /** + * Get the count of collections created by a user + * Used for efficient profile stats without fetching full data + */ + getProfileCollectionCount(authorId: string): Promise; } diff --git a/src/modules/cards/domain/services/IProfileService.ts b/src/modules/cards/domain/services/IProfileService.ts index f06006e3..65f52027 100644 --- a/src/modules/cards/domain/services/IProfileService.ts +++ b/src/modules/cards/domain/services/IProfileService.ts @@ -16,4 +16,6 @@ export interface UserProfile { followerCount?: number; followingCount?: number; followedCollectionsCount?: number; + urlCardCount?: number; + collectionCount?: number; } diff --git a/src/modules/cards/infrastructure/http/controllers/GetMyProfileController.ts b/src/modules/cards/infrastructure/http/controllers/GetMyProfileController.ts index 70f528f8..49506f78 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetMyProfileController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetMyProfileController.ts @@ -19,6 +19,8 @@ export class GetMyProfileController extends Controller { const result = await this.getProfileUseCase.execute({ userId, callerDid: req.did, + includeStats: + req.query.includeStats === 'true' || req.query.includeStats === '1', }); if (result.isErr()) { diff --git a/src/modules/cards/infrastructure/http/controllers/GetUserProfileController.ts b/src/modules/cards/infrastructure/http/controllers/GetUserProfileController.ts index 94948d24..02108e31 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetUserProfileController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetUserProfileController.ts @@ -19,6 +19,8 @@ export class GetUserProfileController extends Controller { const result = await this.getProfileUseCase.execute({ userId: identifier, callerDid: req.did, + includeStats: + req.query.includeStats === 'true' || req.query.includeStats === '1', }); if (result.isErr()) { diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts index 91b3edc4..ef38f451 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts @@ -116,4 +116,10 @@ export class DrizzleCardQueryRepository implements ICardQueryRepository { }> { return this.urlCardQueryService.getUrlAggregateStats(url); } + + async getProfileCardStats(authorId: string): Promise<{ + urlCardCount: number; + }> { + return this.urlCardQueryService.getProfileCardStats(authorId); + } } diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts index c51dea2f..681941b9 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts @@ -582,6 +582,23 @@ export class DrizzleCollectionQueryRepository } } + async getProfileCollectionCount(authorId: string): Promise { + try { + // Count collections created by this user + const result = await this.db + .select({ + count: count(collections.id), + }) + .from(collections) + .where(eq(collections.authorId, authorId)); + + return Number(result[0]?.count || 0); + } catch (error) { + console.error('Error in getProfileCollectionCount:', error); + throw error; + } + } + private getSortColumn(sortBy: CollectionSortField) { switch (sortBy) { case CollectionSortField.NAME: diff --git a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts index da31ac96..3abf933d 100644 --- a/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts +++ b/src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts @@ -1524,6 +1524,31 @@ export class UrlCardQueryService { } } + async getProfileCardStats(authorId: string): Promise<{ + urlCardCount: number; + }> { + try { + // Get count of URL cards authored by this user + const urlCardCountResult = await this.db + .select({ + count: count(cards.id), + }) + .from(cards) + .where( + and(eq(cards.authorId, authorId), eq(cards.type, CardTypeEnum.URL)), + ); + + const urlCardCount = Number(urlCardCountResult[0]?.count || 0); + + return { + urlCardCount, + }; + } catch (error) { + console.error('Error in getProfileCardStats:', error); + throw error; + } + } + private getSortColumn(sortBy: CardSortField) { switch (sortBy) { case CardSortField.CREATED_AT: diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index 12b1971e..3d61253a 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -770,6 +770,7 @@ describe('GetCollectionPageUseCase', () => { getBatchUrlLibraryInfo: jest.fn(), searchUrls: jest.fn(), getUrlAggregateStats: jest.fn(), + getProfileCardStats: jest.fn(), }; const errorUseCase = new GetCollectionPageUseCase( diff --git a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts index f79784ce..db84db89 100644 --- a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts @@ -787,6 +787,7 @@ describe('GetCollectionsForUrlUseCase', () => { getOpenCollectionsWithContributor: jest.fn(), getCollectionContributors: jest.fn(), getCollectionCountForUrl: jest.fn(), + getProfileCollectionCount: jest.fn(), }; const errorUseCase = new GetCollectionsForUrlUseCase( diff --git a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts index acee9041..e97d8337 100644 --- a/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts @@ -373,6 +373,7 @@ describe('GetLibrariesForUrlUseCase', () => { getBatchUrlLibraryInfo: jest.fn(), searchUrls: jest.fn(), getUrlAggregateStats: jest.fn(), + getProfileCardStats: jest.fn(), }; const errorUseCase = new GetLibrariesForUrlUseCase( diff --git a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts index 2f6e9fd1..bf5b7fc0 100644 --- a/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts @@ -678,6 +678,7 @@ describe('GetUrlCardsUseCase', () => { getBatchUrlLibraryInfo: jest.fn(), searchUrls: jest.fn(), getUrlAggregateStats: jest.fn(), + getProfileCardStats: jest.fn(), }; const errorUseCase = new GetUrlCardsUseCase( diff --git a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts index 29f094ea..6862504d 100644 --- a/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts @@ -486,6 +486,7 @@ describe('GetUrlCardViewUseCase', () => { getBatchUrlLibraryInfo: jest.fn(), searchUrls: jest.fn(), getUrlAggregateStats: jest.fn(), + getProfileCardStats: jest.fn(), }; const errorUseCase = new GetUrlCardViewUseCase( diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 679d9e99..e4b981ff 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -599,6 +599,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { getOpenCollectionsWithContributor: jest.fn(), getCollectionContributors: jest.fn(), getCollectionCountForUrl: jest.fn(), + getProfileCollectionCount: jest.fn(), }; const errorUseCase = new GetUrlStatusForMyLibraryUseCase( diff --git a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts index 537ba101..d0579342 100644 --- a/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts @@ -885,6 +885,21 @@ export class InMemoryCardQueryRepository implements ICardQueryRepository { }; } + async getProfileCardStats(authorId: string): Promise<{ + urlCardCount: number; + }> { + const allCards = this.cardRepository.getAllCards(); + + // Count URL cards authored by this user + const urlCardCount = allCards.filter( + (c) => c.type.value === 'URL' && c.curatorId.value === authorId, + ).length; + + return { + urlCardCount, + }; + } + clear(): void { // No separate state to clear } diff --git a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts index 6397b5b4..7d42fbf1 100644 --- a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts @@ -495,6 +495,17 @@ export class InMemoryCollectionQueryRepository return collectionsWithUrl.size; } + async getProfileCollectionCount(authorId: string): Promise { + const allCollections = this.collectionRepository?.getAllCollections() || []; + + // Count collections created by this user + const collectionCount = allCollections.filter( + (c) => c.authorId.value === authorId, + ).length; + + return collectionCount; + } + clear(): void { // No separate state to clear } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index e3534afc..5fe0d839 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -209,6 +209,8 @@ export class UseCaseFactory { services.profileService, services.identityResolutionService, repositories.followsRepository, + repositories.cardQueryRepository, + repositories.collectionQueryRepository, ), refreshAccessTokenUseCase: new RefreshAccessTokenUseCase( services.tokenService, diff --git a/src/types/src/api/common.ts b/src/types/src/api/common.ts index db2ed819..c0a6715a 100644 --- a/src/types/src/api/common.ts +++ b/src/types/src/api/common.ts @@ -11,6 +11,8 @@ export interface User { followerCount?: number; // Number of users following this user followingCount?: number; // Number of users this user follows followedCollectionsCount?: number; // Number of collections this user follows + urlCardCount?: number; // Number of URL cards authored by this user + collectionCount?: number; // Number of collections created by this user } // Extended User interface for contributors with contribution count