diff --git a/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts b/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts index 8ccfeb44..02c1013e 100644 --- a/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts +++ b/src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts @@ -4,6 +4,8 @@ import { UserProfile, } from 'src/modules/cards/domain/services/IProfileService'; import { Result, ok } from 'src/shared/core/Result'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export class CachedBlueskyProfileService implements IProfileService { private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours @@ -12,6 +14,7 @@ export class CachedBlueskyProfileService implements IProfileService { constructor( private readonly profileService: IProfileService, private readonly redis: Redis, + private readonly followsRepository: IFollowsRepository, ) {} async getProfile( @@ -21,31 +24,44 @@ export class CachedBlueskyProfileService implements IProfileService { const cacheKey = this.getCacheKey(userId); try { - // Try cache first + let profile: UserProfile; + + // Try cache first (without follow status) const cached = await this.redis.get(cacheKey); if (cached) { try { - const profile = JSON.parse(cached) as UserProfile; - return ok(profile); + profile = JSON.parse(cached) as UserProfile; } catch (parseError) { // If JSON parsing fails, continue to fetch fresh data console.warn( `Failed to parse cached profile for ${userId}:`, parseError, ); + // Fall through to fetch from service + const result = await this.profileService.getProfile(userId, callerId); + if (result.isErr()) { + return result; + } + profile = result.value; } - } + } else { + // Cache miss - fetch from underlying service + const result = await this.profileService.getProfile(userId, callerId); - // Cache miss or parse error - fetch from underlying service - const result = await this.profileService.getProfile(userId, callerId); + if (result.isErr()) { + return result; + } - if (result.isOk()) { - // Cache the successful result + profile = result.value; + + // Cache the profile (without follow status) try { + const profileToCache = { ...profile }; + delete profileToCache.isFollowing; // Don't cache follow status await this.redis.setex( cacheKey, this.CACHE_TTL_SECONDS, - JSON.stringify(result.value), + JSON.stringify(profileToCache), ); } catch (cacheError) { // Log cache error but don't fail the request @@ -53,7 +69,25 @@ export class CachedBlueskyProfileService implements IProfileService { } } - return result; + // Add follow status if callerId is provided + let isFollowing: boolean | undefined = undefined; + if (callerId && callerId !== userId) { + const followResult = + await this.followsRepository.findByFollowerAndTarget( + callerId, + userId, + FollowTargetType.USER, + ); + + if (followResult.isOk()) { + isFollowing = followResult.value !== null; + } + } + + return ok({ + ...profile, + isFollowing, + }); } catch (redisError) { // If Redis is down, fall back to direct service call console.warn( diff --git a/src/modules/atproto/infrastructure/services/FakeBlueskyProfileService.ts b/src/modules/atproto/infrastructure/services/FakeBlueskyProfileService.ts index 2126627c..8163c8ff 100644 --- a/src/modules/atproto/infrastructure/services/FakeBlueskyProfileService.ts +++ b/src/modules/atproto/infrastructure/services/FakeBlueskyProfileService.ts @@ -3,19 +3,42 @@ import { UserProfile, } from 'src/modules/cards/domain/services/IProfileService'; import { Result, ok, err } from 'src/shared/core/Result'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export class FakeBlueskyProfileService implements IProfileService { - async getProfile(userId: string): Promise> { + constructor(private readonly followsRepository: IFollowsRepository) {} + + async getProfile( + userId: string, + callerId?: string, + ): Promise> { try { // Determine which mock account to use based on userId (DID) const mockData = this.getMockDataForUserId(userId); + // Add follow status if callerId is provided + let isFollowing: boolean | undefined = undefined; + if (callerId && callerId !== userId) { + const followResult = + await this.followsRepository.findByFollowerAndTarget( + callerId, + userId, + FollowTargetType.USER, + ); + + if (followResult.isOk()) { + isFollowing = followResult.value !== null; + } + } + const userProfile: UserProfile = { id: userId, name: mockData.name, handle: mockData.handle, avatarUrl: mockData.avatarUrl, bio: mockData.bio, + isFollowing, }; return ok(userProfile); diff --git a/src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts b/src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts index 3116f6df..b4d3f25c 100644 --- a/src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts @@ -10,6 +10,8 @@ import { import { UrlType } from '../../../domain/value-objects/UrlType'; import { ICollectionRepository } from '../../../domain/ICollectionRepository'; import { IProfileService } from '../../../domain/services/IProfileService'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetCollectionPageQuery { collectionId: string; @@ -68,6 +70,7 @@ export class GetCollectionPageUseCase private collectionRepo: ICollectionRepository, private cardQueryRepo: ICardQueryRepository, private profileService: IProfileService, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -195,6 +198,21 @@ export class GetCollectionPageUseCase }, ); + // Check if the calling user follows this collection + let isFollowing: boolean | undefined = undefined; + if (query.callingUserId) { + const followResult = + await this.followsRepository.findByFollowerAndTarget( + query.callingUserId, + collection.collectionId.getStringValue(), + FollowTargetType.COLLECTION, + ); + + if (followResult.isOk()) { + isFollowing = followResult.value !== null; + } + } + return ok({ id: collection.collectionId.getStringValue(), uri: collectionUri, @@ -211,6 +229,7 @@ export class GetCollectionPageUseCase cardCount: collection.cardCount, createdAt: collection.createdAt.toISOString(), updatedAt: collection.updatedAt.toISOString(), + isFollowing, pagination: { currentPage: page, totalPages: Math.ceil(cardsResult.totalCount / limit), diff --git a/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts index 3f1a8086..a3872f63 100644 --- a/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts @@ -10,6 +10,8 @@ import { IProfileService } from '../../../domain/services/IProfileService'; import { ICollectionRepository } from '../../../domain/ICollectionRepository'; import { CollectionId } from '../../../domain/value-objects/CollectionId'; import { GetCollectionsForUrlResponse, Collection } from '@semble/types'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetCollectionsForUrlQuery { url: string; @@ -38,6 +40,7 @@ export class GetCollectionsForUrlUseCase private collectionQueryRepo: ICollectionQueryRepository, private profileService: IProfileService, private collectionRepo: ICollectionRepository, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -150,6 +153,24 @@ export class GetCollectionsForUrlUseCase }), ); + // Add follow status if callingUserId is provided + if (query.callingUserId) { + const followChecks = await Promise.all( + enrichedCollections.map((c) => + this.followsRepository.findByFollowerAndTarget( + query.callingUserId!, + c.id, + FollowTargetType.COLLECTION, + ), + ), + ); + + enrichedCollections.forEach((collection, i) => { + collection.isFollowing = + followChecks[i]?.isOk() && followChecks[i].value !== null; + }); + } + return ok({ collections: enrichedCollections, pagination: { diff --git a/src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts b/src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts index 43e1a2fb..e0b3bc5f 100644 --- a/src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts @@ -14,9 +14,12 @@ import { CollectionSortingDTO, } from '@semble/types'; import { CollectionAccessType } from '../../../domain/Collection'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetCollectionsQuery { curatorId: string; + callingUserId?: string; page?: number; limit?: number; sortBy?: CollectionSortField; @@ -44,6 +47,7 @@ export class GetCollectionsUseCase private collectionQueryRepo: ICollectionQueryRepository, private profileService: IProfileService, private identityResolver: IIdentityResolutionService, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -118,6 +122,24 @@ export class GetCollectionsUseCase }; }); + // Add follow status if callingUserId is provided + if (query.callingUserId) { + const followChecks = await Promise.all( + enrichedCollections.map((c) => + this.followsRepository.findByFollowerAndTarget( + query.callingUserId!, + c.id, + FollowTargetType.COLLECTION, + ), + ), + ); + + enrichedCollections.forEach((collection, i) => { + collection.isFollowing = + followChecks[i]?.isOk() && followChecks[i].value !== null; + }); + } + return ok({ collections: enrichedCollections, pagination: { diff --git a/src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts b/src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts index ed95dd75..39019e89 100644 --- a/src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts @@ -14,9 +14,12 @@ import { import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; import { CollectionAccessType } from '../../../domain/Collection'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetOpenCollectionsWithContributorQuery { contributorId: string; // DID or handle + callingUserId?: string; page?: number; limit?: number; sortBy?: CollectionSortField; @@ -47,6 +50,7 @@ export class GetOpenCollectionsWithContributorUseCase private collectionQueryRepo: ICollectionQueryRepository, private profileService: IProfileService, private identityResolver: IIdentityResolutionService, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -135,6 +139,24 @@ export class GetOpenCollectionsWithContributorUseCase }) .filter((item): item is NonNullable => item !== null); + // Add follow status if callingUserId is provided + if (query.callingUserId) { + const followChecks = await Promise.all( + enrichedCollections.map((c) => + this.followsRepository.findByFollowerAndTarget( + query.callingUserId!, + c.id, + FollowTargetType.COLLECTION, + ), + ), + ); + + enrichedCollections.forEach((collection, i) => { + collection.isFollowing = + followChecks[i]?.isOk() && followChecks[i].value !== null; + }); + } + return ok({ collections: enrichedCollections, pagination: { diff --git a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts index b7007d6e..c340b8fc 100644 --- a/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetProfileUseCase.ts @@ -15,6 +15,7 @@ export interface GetMyProfileResult { handle: string; description?: string; avatarUrl?: string; + isFollowing?: boolean; } export class ValidationError extends Error { @@ -91,6 +92,7 @@ export class GetProfileUseCase handle: profile.handle, description: profile.bio, avatarUrl: profile.avatarUrl, + isFollowing: profile.isFollowing, }); } catch (error) { return err( diff --git a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts index 73461463..877d8f62 100644 --- a/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts @@ -13,6 +13,8 @@ import { URL } from '../../../domain/value-objects/URL'; import { CollectionId } from '../../../domain/value-objects/CollectionId'; import { CollectionDTO, UrlCard } from '@semble/types'; import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetUrlStatusForMyLibraryQuery { url: string; @@ -43,6 +45,7 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< private collectionQueryRepository: ICollectionQueryRepository, private collectionRepo: ICollectionRepository, private profileService: IProfileService, + private followsRepository: IFollowsRepository, eventPublisher: IEventPublisher, ) { super(eventPublisher); @@ -156,7 +159,7 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< ); // Enrich collections with full data - result.collections = await Promise.all( + const enrichedCollections = await Promise.all( collections.map(async (collection): Promise => { // Fetch full collection to get dates and cardCount const collectionIdResult = CollectionId.createFromString( @@ -210,6 +213,24 @@ export class GetUrlStatusForMyLibraryUseCase extends BaseUseCase< }; }), ); + + // Add follow status for collections + const followChecks = await Promise.all( + enrichedCollections.map((c) => + this.followsRepository.findByFollowerAndTarget( + curatorId.value, + c.id, + FollowTargetType.COLLECTION, + ), + ), + ); + + enrichedCollections.forEach((collection, i) => { + collection.isFollowing = + followChecks[i]?.isOk() && followChecks[i].value !== null; + }); + + result.collections = enrichedCollections; } catch (error) { // Propagate authentication errors if (error instanceof AuthenticationError) { diff --git a/src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts b/src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts index f859cbfd..de5221bc 100644 --- a/src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts +++ b/src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts @@ -14,8 +14,11 @@ import { import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; import { CollectionAccessType } from '../../../domain/Collection'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface SearchCollectionsQuery { + callingUserId?: string; page?: number; limit?: number; sortBy?: CollectionSortField; @@ -38,6 +41,7 @@ export class SearchCollectionsUseCase private collectionQueryRepo: ICollectionQueryRepository, private profileService: IProfileService, private identityResolutionService: IIdentityResolutionService, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -139,6 +143,24 @@ export class SearchCollectionsUseCase }) .filter((item): item is NonNullable => item !== null); + // Add follow status if callingUserId is provided + if (query.callingUserId) { + const followChecks = await Promise.all( + enrichedCollections.map((c) => + this.followsRepository.findByFollowerAndTarget( + query.callingUserId!, + c.id, + FollowTargetType.COLLECTION, + ), + ), + ); + + enrichedCollections.forEach((collection, i) => { + collection.isFollowing = + followChecks[i]?.isOk() && followChecks[i].value !== null; + }); + } + return ok({ collections: enrichedCollections, pagination: { diff --git a/src/modules/cards/domain/services/IProfileService.ts b/src/modules/cards/domain/services/IProfileService.ts index 5ee3048b..66a51d22 100644 --- a/src/modules/cards/domain/services/IProfileService.ts +++ b/src/modules/cards/domain/services/IProfileService.ts @@ -10,4 +10,5 @@ export interface UserProfile { handle: string; avatarUrl?: string; bio?: string; + isFollowing?: boolean; } diff --git a/src/modules/cards/infrastructure/http/controllers/GetMyCollectionsController.ts b/src/modules/cards/infrastructure/http/controllers/GetMyCollectionsController.ts index bdc81bb3..6bd49e0d 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetMyCollectionsController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetMyCollectionsController.ts @@ -23,6 +23,7 @@ export class GetMyCollectionsController extends Controller { const result = await this.getCollectionsUseCase.execute({ curatorId, + callingUserId: curatorId, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, sortBy: sortBy as CollectionSortField, diff --git a/src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController.ts b/src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController.ts index 2debbd3b..94af8589 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController.ts @@ -1,10 +1,11 @@ import { Controller } from '../../../../../shared/infrastructure/http/Controller'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { GetOpenCollectionsWithContributorUseCase } from '../../../application/useCases/queries/GetOpenCollectionsWithContributorUseCase'; import { CollectionSortField, SortOrder, } from '../../../domain/ICollectionQueryRepository'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; export class GetOpenCollectionsWithContributorController extends Controller { constructor( @@ -13,10 +14,11 @@ export class GetOpenCollectionsWithContributorController extends Controller { super(); } - async executeImpl(req: Request, res: Response): Promise { + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { try { const { identifier } = req.params; const { page, limit, sortBy, sortOrder } = req.query; + const callerDid = req.did; if (!identifier) { return this.fail(res, 'Identifier (DID or handle) is required'); @@ -25,6 +27,7 @@ export class GetOpenCollectionsWithContributorController extends Controller { const result = await this.getOpenCollectionsWithContributorUseCase.execute({ contributorId: identifier, + callingUserId: callerDid, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, sortBy: sortBy as CollectionSortField, diff --git a/src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController.ts b/src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController.ts index b9e6e953..73734b57 100644 --- a/src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController.ts +++ b/src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController.ts @@ -1,20 +1,22 @@ import { Controller } from '../../../../../shared/infrastructure/http/Controller'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { GetCollectionsUseCase } from '../../../application/useCases/queries/GetCollectionsUseCase'; import { CollectionSortField, SortOrder, } from '../../../domain/ICollectionQueryRepository'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; export class GetUserCollectionsController extends Controller { constructor(private getCollectionsUseCase: GetCollectionsUseCase) { super(); } - async executeImpl(req: Request, res: Response): Promise { + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { try { const { identifier } = req.params; const { page, limit, sortBy, sortOrder, searchText } = req.query; + const callerDid = req.did; if (!identifier) { return this.fail(res, 'Identifier (DID or handle) is required'); @@ -22,6 +24,7 @@ export class GetUserCollectionsController extends Controller { const result = await this.getCollectionsUseCase.execute({ curatorId: identifier, + callingUserId: callerDid, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, sortBy: sortBy as CollectionSortField, diff --git a/src/modules/cards/infrastructure/http/controllers/SearchCollectionsController.ts b/src/modules/cards/infrastructure/http/controllers/SearchCollectionsController.ts index 3e3762a3..2b5949f9 100644 --- a/src/modules/cards/infrastructure/http/controllers/SearchCollectionsController.ts +++ b/src/modules/cards/infrastructure/http/controllers/SearchCollectionsController.ts @@ -1,18 +1,19 @@ import { Controller } from '../../../../../shared/infrastructure/http/Controller'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { SearchCollectionsUseCase } from '../../../application/useCases/queries/SearchCollectionsUseCase'; import { CollectionSortField, SortOrder, } from '../../../domain/ICollectionQueryRepository'; import { CollectionAccessType } from '../../../domain/Collection'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; export class SearchCollectionsController extends Controller { constructor(private searchCollectionsUseCase: SearchCollectionsUseCase) { super(); } - async executeImpl(req: Request, res: Response): Promise { + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { try { const { page, @@ -23,8 +24,10 @@ export class SearchCollectionsController extends Controller { identifier, accessType, } = req.query; + const callerDid = req.did; const result = await this.searchCollectionsUseCase.execute({ + callingUserId: callerDid, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, sortBy: sortBy as CollectionSortField, diff --git a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts index e5158516..73783619 100644 --- a/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts @@ -14,6 +14,7 @@ import { URL } from '../../domain/value-objects/URL'; import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; import { ICollectionRepository } from '../../domain/ICollectionRepository'; +import { InMemoryFollowsRepository } from '../../../user/tests/infrastructure/InMemoryFollowsRepository'; describe('GetCollectionPageUseCase', () => { let useCase: GetCollectionPageUseCase; @@ -21,6 +22,7 @@ describe('GetCollectionPageUseCase', () => { let cardRepo: InMemoryCardRepository; let cardQueryRepo: InMemoryCardQueryRepository; let profileService: FakeProfileService; + let followsRepository: InMemoryFollowsRepository; let curatorId: CuratorId; let collectionId: CollectionId; @@ -29,10 +31,12 @@ describe('GetCollectionPageUseCase', () => { cardRepo = InMemoryCardRepository.getInstance(); cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo); profileService = new FakeProfileService(); + followsRepository = InMemoryFollowsRepository.getInstance(); useCase = new GetCollectionPageUseCase( collectionRepo, cardQueryRepo, profileService, + followsRepository, ); curatorId = CuratorId.create('did:plc:testcurator').unwrap(); @@ -716,6 +720,7 @@ describe('GetCollectionPageUseCase', () => { errorCollectionRepo, cardQueryRepo, profileService, + followsRepository, ); const query = { @@ -766,6 +771,7 @@ describe('GetCollectionPageUseCase', () => { collectionRepo, errorCardQueryRepo, profileService, + followsRepository, ); const query = { diff --git a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts index 0b2e3d5b..9ad64ab6 100644 --- a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts @@ -13,6 +13,7 @@ import { SortOrder, } from '../../domain/ICollectionQueryRepository'; import { FakeProfileService } from '../utils/FakeProfileService'; +import { InMemoryFollowsRepository } from '../../../user/tests/infrastructure/InMemoryFollowsRepository'; describe('GetCollectionsForUrlUseCase', () => { let useCase: GetCollectionsForUrlUseCase; @@ -20,6 +21,7 @@ describe('GetCollectionsForUrlUseCase', () => { let collectionRepository: InMemoryCollectionRepository; let collectionQueryRepository: InMemoryCollectionQueryRepository; let profileService: FakeProfileService; + let followsRepository: InMemoryFollowsRepository; let curator1: CuratorId; let curator2: CuratorId; let curator3: CuratorId; @@ -32,11 +34,13 @@ describe('GetCollectionsForUrlUseCase', () => { cardRepository, ); profileService = new FakeProfileService(); + followsRepository = InMemoryFollowsRepository.getInstance(); useCase = new GetCollectionsForUrlUseCase( collectionQueryRepository, profileService, collectionRepository, + followsRepository, ); curator1 = CuratorId.create('did:plc:curator1').unwrap(); @@ -787,6 +791,7 @@ describe('GetCollectionsForUrlUseCase', () => { errorCollectionQueryRepository, profileService, collectionRepository, + followsRepository, ); const query = { diff --git a/src/modules/cards/tests/application/GetMyCollectionsUseCase.test.ts b/src/modules/cards/tests/application/GetMyCollectionsUseCase.test.ts index 018d9b31..11e52298 100644 --- a/src/modules/cards/tests/application/GetMyCollectionsUseCase.test.ts +++ b/src/modules/cards/tests/application/GetMyCollectionsUseCase.test.ts @@ -11,6 +11,7 @@ import { } from '../../domain/ICollectionQueryRepository'; import { UserProfile } from '../../domain/services/IProfileService'; import { GetCollectionsUseCase } from '../../application/useCases/queries/GetCollectionsUseCase'; +import { InMemoryFollowsRepository } from '../../../user/tests/infrastructure/InMemoryFollowsRepository'; describe('GetMyCollectionsUseCase', () => { let useCase: GetCollectionsUseCase; @@ -18,6 +19,7 @@ describe('GetMyCollectionsUseCase', () => { let collectionRepo: InMemoryCollectionRepository; let profileService: FakeProfileService; let identityResolutionService: FakeIdentityResolutionService; + let followsRepository: InMemoryFollowsRepository; let curatorId: CuratorId; let userProfile: UserProfile; @@ -26,10 +28,12 @@ describe('GetMyCollectionsUseCase', () => { collectionQueryRepo = new InMemoryCollectionQueryRepository(collectionRepo); profileService = new FakeProfileService(); identityResolutionService = new FakeIdentityResolutionService(); + followsRepository = InMemoryFollowsRepository.getInstance(); useCase = new GetCollectionsUseCase( collectionQueryRepo, profileService, identityResolutionService, + followsRepository, ); curatorId = CuratorId.create('did:plc:testcurator').unwrap(); diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts index 7fc00c8e..046c5009 100644 --- a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -15,6 +15,7 @@ import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId' import { URL } from '../../domain/value-objects/URL'; import { err } from 'src/shared/core/Result'; import { ICardRepository } from '../../domain/ICardRepository'; +import { InMemoryFollowsRepository } from '../../../user/tests/infrastructure/InMemoryFollowsRepository'; describe('GetUrlStatusForMyLibraryUseCase', () => { let useCase: GetUrlStatusForMyLibraryUseCase; @@ -26,6 +27,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { let collectionPublisher: FakeCollectionPublisher; let eventPublisher: FakeEventPublisher; let profileService: FakeProfileService; + let followsRepository: InMemoryFollowsRepository; let curatorId: CuratorId; let otherCuratorId: CuratorId; @@ -43,6 +45,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { collectionPublisher = new FakeCollectionPublisher(); eventPublisher = new FakeEventPublisher(); profileService = new FakeProfileService(); + followsRepository = InMemoryFollowsRepository.getInstance(); useCase = new GetUrlStatusForMyLibraryUseCase( cardRepository, @@ -50,6 +53,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { collectionQueryRepository, collectionRepository, profileService, + followsRepository, eventPublisher, ); @@ -543,6 +547,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { collectionQueryRepository, collectionRepository, profileService, + followsRepository, eventPublisher, ); @@ -600,6 +605,7 @@ describe('GetUrlStatusForMyLibraryUseCase', () => { errorCollectionQueryRepository, collectionRepository, profileService, + followsRepository, eventPublisher, ); diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index a1c15de3..3ba08602 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -14,6 +14,8 @@ import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionI import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; import { CollectionAccessType } from '../../../../cards/domain/Collection'; +import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; +import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; export interface GetGlobalFeedQuery { callingUserId?: string; @@ -45,6 +47,7 @@ export class GetGlobalFeedUseCase private profileService: IProfileService, private cardQueryRepository: ICardQueryRepository, private collectionRepository: ICollectionRepository, + private followsRepository: IFollowsRepository, ) {} async execute( @@ -307,6 +310,30 @@ export class GetGlobalFeedUseCase } }); + // Add follow status for collections if callingUserId is provided + const collectionFollowStatusMap = new Map(); + if (query.callingUserId && collectionIds.length > 0) { + const followChecks = await Promise.all( + collectionIds.map((collectionId) => + this.followsRepository.findByFollowerAndTarget( + query.callingUserId!, + collectionId, + FollowTargetType.COLLECTION, + ), + ), + ); + + followChecks.forEach((followResult, i) => { + const collectionId = collectionIds[i]; + if (collectionId) { + collectionFollowStatusMap.set( + collectionId, + followResult?.isOk() && followResult.value !== null, + ); + } + }); + } + // Transform activities to FeedItem const feedItems: FeedItem[] = []; for (const activity of feed.activities) { @@ -356,21 +383,30 @@ export class GetGlobalFeedUseCase }; const collections = (activity.metadata.collectionIds || []) - .map((collectionId) => collectionDataMap.get(collectionId)) - .filter((collection) => !!collection) - .filter((collection) => - collection.cardIds.has(activity.metadata.cardId), + .map((collectionId) => { + const collection = collectionDataMap.get(collectionId); + if (!collection) return null; + + return { + collection, + collectionId, + }; + }) + .filter((item) => !!item) + .filter((item) => + item.collection.cardIds.has(activity.metadata.cardId), ) - .map((collection) => ({ - id: collection.id, - uri: collection.uri, - name: collection.name, - description: collection.description, - accessType: collection.accessType, - author: collection.author, - cardCount: collection.cardCount, - createdAt: collection.createdAt, - updatedAt: collection.updatedAt, + .map((item) => ({ + id: item.collection.id, + uri: item.collection.uri, + name: item.collection.name, + description: item.collection.description, + accessType: item.collection.accessType, + author: item.collection.author, + cardCount: item.collection.cardCount, + createdAt: item.collection.createdAt, + updatedAt: item.collection.updatedAt, + isFollowing: collectionFollowStatusMap.get(item.collectionId), })); feedItems.push({ diff --git a/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts b/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts index 7a483c28..0c76a324 100644 --- a/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts +++ b/src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts @@ -133,6 +133,7 @@ export class GetCollectionFollowersUseCase handle: profile.handle, avatarUrl: profile.avatarUrl, description: profile.bio, + isFollowing: profile.isFollowing, }); } diff --git a/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts index 0e631b9d..1a7f7085 100644 --- a/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts +++ b/src/modules/user/application/useCases/queries/GetFollowersUseCase.ts @@ -121,6 +121,7 @@ export class GetFollowersUseCase handle: profile.handle, avatarUrl: profile.avatarUrl, description: profile.bio, + isFollowing: profile.isFollowing, }); } diff --git a/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts index 6c3644af..75e962d5 100644 --- a/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts +++ b/src/modules/user/application/useCases/queries/GetFollowingCollectionsUseCase.ts @@ -176,6 +176,7 @@ export class GetFollowingCollectionsUseCase cardCount: collection.cardCount, createdAt: collection.createdAt.toISOString(), updatedAt: collection.updatedAt.toISOString(), + isFollowing: true, // The calling user is following all these collections by definition } as Collection; }) .filter((collection): collection is Collection => collection !== null); diff --git a/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts b/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts index 8aa686b4..53494dc3 100644 --- a/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts +++ b/src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts @@ -119,6 +119,7 @@ export class GetFollowingUsersUseCase handle: profile.handle, avatarUrl: profile.avatarUrl, description: profile.bio, + isFollowing: profile.isFollowing, }); } diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index 99ef5c5a..19ff6e40 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -349,6 +349,7 @@ export class ServiceFactory { profileService = new CachedBlueskyProfileService( baseProfileService, redis, + repositories.followsRepository, ); } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 0428a7d3..1573bc80 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -159,6 +159,7 @@ export class UseCaseFactory { repositories.collectionRepository, repositories.cardQueryRepository, services.profileService, + repositories.followsRepository, ); return { @@ -321,17 +322,20 @@ export class UseCaseFactory { repositories.collectionQueryRepository, services.profileService, services.identityResolutionService, + repositories.followsRepository, ), searchCollectionsUseCase: new SearchCollectionsUseCase( repositories.collectionQueryRepository, services.profileService, services.identityResolutionService, + repositories.followsRepository, ), getOpenCollectionsWithContributorUseCase: new GetOpenCollectionsWithContributorUseCase( repositories.collectionQueryRepository, services.profileService, services.identityResolutionService, + repositories.followsRepository, ), getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( repositories.cardRepository, @@ -339,6 +343,7 @@ export class UseCaseFactory { repositories.collectionQueryRepository, repositories.collectionRepository, services.profileService, + repositories.followsRepository, services.eventPublisher, ), getLibrariesForUrlUseCase: new GetLibrariesForUrlUseCase( @@ -349,6 +354,7 @@ export class UseCaseFactory { repositories.collectionQueryRepository, services.profileService, repositories.collectionRepository, + repositories.followsRepository, ), getNoteCardsForUrlUseCase: new GetNoteCardsForUrlUseCase( repositories.cardQueryRepository, @@ -361,6 +367,7 @@ export class UseCaseFactory { services.profileService, repositories.cardQueryRepository, repositories.collectionRepository, + repositories.followsRepository, ), getGemActivityFeedUseCase: new GetGemActivityFeedUseCase( repositories.feedRepository, diff --git a/src/types/src/api/common.ts b/src/types/src/api/common.ts index b148e96f..d265f9de 100644 --- a/src/types/src/api/common.ts +++ b/src/types/src/api/common.ts @@ -5,6 +5,7 @@ export interface User { handle: string; avatarUrl?: string; description?: string; + isFollowing?: boolean; // Whether the calling user follows this user } // Base pagination interface diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index d9f480ef..25dc3644 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -101,6 +101,7 @@ export interface Collection { cardCount: number; createdAt: string; updatedAt: string; + isFollowing?: boolean; // Whether the calling user follows this collection } // Context-specific variations