diff --git a/src/modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler.ts b/src/modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler.ts index 9091308f..f8fbdbb5 100644 --- a/src/modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler.ts +++ b/src/modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler.ts @@ -14,8 +14,24 @@ export class CardAddedToCollectionEventHandler `[FEEDS] Processing CardAddedToCollectionEvent for card ${event.cardId.getStringValue()} added to collection ${event.collectionId.getStringValue()}`, ); - // For now, we'll just log the event - you can extend this to update feeds - // const result = await this.feedService.processCardAddedToCollection(event); + // TODO: We need collection names and card metadata to create a proper activity + // For now, we'll create a basic activity with just IDs + const result = await this.feedService.addCardAddedToCollectionActivity( + event.curatorId, + event.cardId, + [event.collectionId], + ['Unknown Collection'], // TODO: Fetch actual collection name + undefined, // TODO: Fetch card title + undefined, // TODO: Fetch card URL + ); + + if (result.isErr()) { + console.error( + '[FEEDS] Failed to add card-added-to-collection activity:', + result.error, + ); + return err(result.error); + } console.log( `[FEEDS] Successfully processed CardAddedToCollectionEvent for card ${event.cardId.getStringValue()}`, diff --git a/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts new file mode 100644 index 00000000..c89b52be --- /dev/null +++ b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts @@ -0,0 +1,119 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { IFeedService } from '../../ports/IFeedService'; +import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; +import { CardId } from '../../../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; +import { ActivityTypeEnum } from '../../../domain/value-objects/ActivityType'; + +export interface AddCardToLibraryActivityDTO { + type: ActivityTypeEnum.CARD_ADDED_TO_LIBRARY; + actorId: string; + cardId: string; + cardTitle?: string; + cardUrl?: string; +} + +export interface AddCardToCollectionActivityDTO { + type: ActivityTypeEnum.CARD_ADDED_TO_COLLECTION; + actorId: string; + cardId: string; + collectionIds: string[]; + collectionNames: string[]; + cardTitle?: string; + cardUrl?: string; +} + +export type AddActivityToFeedDTO = + | AddCardToLibraryActivityDTO + | AddCardToCollectionActivityDTO; + +export interface AddActivityToFeedResponseDTO { + activityId: string; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class AddActivityToFeedUseCase + implements UseCase> +{ + constructor(private feedService: IFeedService) {} + + async execute( + request: AddActivityToFeedDTO, + ): Promise> { + try { + // Validate and create CuratorId + const actorIdResult = CuratorId.create(request.actorId); + if (actorIdResult.isErr()) { + return err(new ValidationError(`Invalid actor ID: ${actorIdResult.error.message}`)); + } + const actorId = actorIdResult.value; + + // Validate and create CardId + const cardIdResult = CardId.createFromString(request.cardId); + if (cardIdResult.isErr()) { + return err(new ValidationError(`Invalid card ID: ${cardIdResult.error.message}`)); + } + const cardId = cardIdResult.value; + + let activityResult; + + switch (request.type) { + case ActivityTypeEnum.CARD_ADDED_TO_LIBRARY: + activityResult = await this.feedService.addCardAddedToLibraryActivity( + actorId, + cardId, + request.cardTitle, + request.cardUrl, + ); + break; + + case ActivityTypeEnum.CARD_ADDED_TO_COLLECTION: + // Validate collection IDs + const collectionIds: CollectionId[] = []; + for (const collectionIdStr of request.collectionIds) { + const collectionIdResult = CollectionId.createFromString(collectionIdStr); + if (collectionIdResult.isErr()) { + return err(new ValidationError(`Invalid collection ID: ${collectionIdResult.error.message}`)); + } + collectionIds.push(collectionIdResult.value); + } + + // Validate collection names array + if (request.collectionNames.length !== request.collectionIds.length) { + return err(new ValidationError('Collection IDs and names arrays must have the same length')); + } + + activityResult = await this.feedService.addCardAddedToCollectionActivity( + actorId, + cardId, + collectionIds, + request.collectionNames, + request.cardTitle, + request.cardUrl, + ); + break; + + default: + return err(new ValidationError(`Unsupported activity type: ${(request as any).type}`)); + } + + if (activityResult.isErr()) { + return err(new ValidationError(activityResult.error.message)); + } + + return ok({ + activityId: activityResult.value.activityId.getStringValue(), + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts new file mode 100644 index 00000000..04ff0365 --- /dev/null +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -0,0 +1,177 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { IFeedRepository } from '../../../domain/IFeedRepository'; +import { ActivityId } from '../../../domain/value-objects/ActivityId'; +import { ActivityTypeEnum } from '../../../domain/value-objects/ActivityType'; +import { IProfileService } from '../../../../cards/domain/services/IProfileService'; + +export interface GetGlobalFeedQuery { + page?: number; + limit?: number; + beforeActivityId?: string; // For cursor-based pagination +} + +// DTOs for the response +export interface ActivityActorDTO { + id: string; + name: string; + handle: string; + avatarUrl?: string; +} + +export interface CardAddedToLibraryActivityDTO { + id: string; + type: ActivityTypeEnum.CARD_ADDED_TO_LIBRARY; + actor: ActivityActorDTO; + cardId: string; + cardTitle?: string; + cardUrl?: string; + createdAt: Date; +} + +export interface CardAddedToCollectionActivityDTO { + id: string; + type: ActivityTypeEnum.CARD_ADDED_TO_COLLECTION; + actor: ActivityActorDTO; + cardId: string; + cardTitle?: string; + cardUrl?: string; + collectionIds: string[]; + collectionNames: string[]; + createdAt: Date; +} + +export type ActivityDTO = + | CardAddedToLibraryActivityDTO + | CardAddedToCollectionActivityDTO; + +export interface GetGlobalFeedResult { + activities: ActivityDTO[]; + pagination: { + currentPage: number; + totalCount: number; + hasMore: boolean; + limit: number; + nextCursor?: string; + }; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class GetGlobalFeedUseCase + implements UseCase> +{ + constructor( + private feedRepository: IFeedRepository, + private profileService: IProfileService, + ) {} + + async execute( + query: GetGlobalFeedQuery, + ): Promise> { + try { + // Set defaults and validate + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + + let beforeActivityId: ActivityId | undefined; + if (query.beforeActivityId) { + const activityIdResult = ActivityId.createFromString(query.beforeActivityId); + if (activityIdResult.isErr()) { + return err(new ValidationError(`Invalid beforeActivityId: ${activityIdResult.error.message}`)); + } + beforeActivityId = activityIdResult.value; + } + + // Fetch activities from repository + const feedResult = await this.feedRepository.getGlobalFeed({ + page, + limit, + beforeActivityId, + }); + + if (feedResult.isErr()) { + return err(AppError.UnexpectedError.create(feedResult.error)); + } + + const feed = feedResult.value; + + // Get unique actor IDs for profile enrichment + const actorIds = [...new Set(feed.activities.map(activity => activity.actorId.value))]; + + // Fetch profiles for all actors + const actorProfiles = new Map(); + for (const actorId of actorIds) { + const profileResult = await this.profileService.getProfile(actorId); + if (profileResult.isOk()) { + const profile = profileResult.value; + actorProfiles.set(actorId, { + id: profile.id, + name: profile.name, + handle: profile.handle, + avatarUrl: profile.avatarUrl, + }); + } else { + // If profile fetch fails, create a fallback + actorProfiles.set(actorId, { + id: actorId, + name: 'Unknown User', + handle: actorId, + }); + } + } + + // Transform activities to DTOs + const activityDTOs: ActivityDTO[] = feed.activities.map(activity => { + const actor = actorProfiles.get(activity.actorId.value)!; + + if (activity.isCardAddedToLibrary) { + const metadata = activity.cardAddedToLibraryMetadata!; + return { + id: activity.activityId.getStringValue(), + type: ActivityTypeEnum.CARD_ADDED_TO_LIBRARY, + actor, + cardId: metadata.cardId, + cardTitle: metadata.cardTitle, + cardUrl: metadata.cardUrl, + createdAt: activity.createdAt, + } as CardAddedToLibraryActivityDTO; + } else if (activity.isCardAddedToCollection) { + const metadata = activity.cardAddedToCollectionMetadata!; + return { + id: activity.activityId.getStringValue(), + type: ActivityTypeEnum.CARD_ADDED_TO_COLLECTION, + actor, + cardId: metadata.cardId, + cardTitle: metadata.cardTitle, + cardUrl: metadata.cardUrl, + collectionIds: metadata.collectionIds, + collectionNames: metadata.collectionNames, + createdAt: activity.createdAt, + } as CardAddedToCollectionActivityDTO; + } else { + throw new Error(`Unsupported activity type: ${activity.type.value}`); + } + }); + + return ok({ + activities: activityDTOs, + pagination: { + currentPage: page, + totalCount: feed.totalCount, + hasMore: feed.hasMore, + limit, + nextCursor: feed.nextCursor?.getStringValue(), + }, + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/feeds/domain/Activity.ts b/src/modules/feeds/domain/Activity.ts new file mode 100644 index 00000000..040a8d1f --- /dev/null +++ b/src/modules/feeds/domain/Activity.ts @@ -0,0 +1,154 @@ +import { Entity } from '../../../shared/domain/Entity'; +import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID'; +import { Result, ok, err } from '../../../shared/core/Result'; +import { ActivityId } from './value-objects/ActivityId'; +import { ActivityType, ActivityTypeEnum } from './value-objects/ActivityType'; +import { CuratorId } from '../../cards/domain/value-objects/CuratorId'; +import { CardId } from '../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../cards/domain/value-objects/CollectionId'; + +export class ActivityValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ActivityValidationError'; + } +} + +// Activity metadata for different activity types +export interface CardAddedToLibraryMetadata { + cardId: string; + cardTitle?: string; + cardUrl?: string; +} + +export interface CardAddedToCollectionMetadata { + cardId: string; + cardTitle?: string; + cardUrl?: string; + collectionIds: string[]; + collectionNames: string[]; +} + +export type ActivityMetadata = + | CardAddedToLibraryMetadata + | CardAddedToCollectionMetadata; + +interface ActivityProps { + actorId: CuratorId; // The user who performed the activity + type: ActivityType; + metadata: ActivityMetadata; + createdAt: Date; +} + +export class Activity extends Entity { + get activityId(): ActivityId { + return ActivityId.create(this._id).unwrap(); + } + + get actorId(): CuratorId { + return this.props.actorId; + } + + get type(): ActivityType { + return this.props.type; + } + + get metadata(): ActivityMetadata { + return this.props.metadata; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + // Type guards for metadata + get isCardAddedToLibrary(): boolean { + return this.props.type.value === ActivityTypeEnum.CARD_ADDED_TO_LIBRARY; + } + + get isCardAddedToCollection(): boolean { + return this.props.type.value === ActivityTypeEnum.CARD_ADDED_TO_COLLECTION; + } + + get cardAddedToLibraryMetadata(): CardAddedToLibraryMetadata | null { + return this.isCardAddedToLibrary ? this.props.metadata as CardAddedToLibraryMetadata : null; + } + + get cardAddedToCollectionMetadata(): CardAddedToCollectionMetadata | null { + return this.isCardAddedToCollection ? this.props.metadata as CardAddedToCollectionMetadata : null; + } + + private constructor(props: ActivityProps, id?: UniqueEntityID) { + super(props, id); + } + + public static createCardAddedToLibrary( + actorId: CuratorId, + cardId: CardId, + cardTitle?: string, + cardUrl?: string, + createdAt?: Date, + id?: UniqueEntityID, + ): Result { + const typeResult = ActivityType.cardAddedToLibrary(); + if (typeResult.isErr()) { + return err(new ActivityValidationError(typeResult.error.message)); + } + + const metadata: CardAddedToLibraryMetadata = { + cardId: cardId.getStringValue(), + cardTitle, + cardUrl, + }; + + const props: ActivityProps = { + actorId, + type: typeResult.value, + metadata, + createdAt: createdAt || new Date(), + }; + + return ok(new Activity(props, id)); + } + + public static createCardAddedToCollection( + actorId: CuratorId, + cardId: CardId, + collectionIds: CollectionId[], + collectionNames: string[], + cardTitle?: string, + cardUrl?: string, + createdAt?: Date, + id?: UniqueEntityID, + ): Result { + if (collectionIds.length !== collectionNames.length) { + return err(new ActivityValidationError('Collection IDs and names arrays must have the same length')); + } + + if (collectionIds.length === 0) { + return err(new ActivityValidationError('At least one collection must be specified')); + } + + const typeResult = ActivityType.cardAddedToCollection(); + if (typeResult.isErr()) { + return err(new ActivityValidationError(typeResult.error.message)); + } + + const metadata: CardAddedToCollectionMetadata = { + cardId: cardId.getStringValue(), + cardTitle, + cardUrl, + collectionIds: collectionIds.map(id => id.getStringValue()), + collectionNames, + }; + + const props: ActivityProps = { + actorId, + type: typeResult.value, + metadata, + createdAt: createdAt || new Date(), + }; + + return ok(new Activity(props, id)); + } +} diff --git a/src/modules/feeds/domain/IFeedRepository.ts b/src/modules/feeds/domain/IFeedRepository.ts new file mode 100644 index 00000000..226e524a --- /dev/null +++ b/src/modules/feeds/domain/IFeedRepository.ts @@ -0,0 +1,22 @@ +import { Result } from '../../../shared/core/Result'; +import { Activity } from './Activity'; +import { ActivityId } from './value-objects/ActivityId'; + +export interface FeedQueryOptions { + page: number; + limit: number; + beforeActivityId?: ActivityId; // For cursor-based pagination +} + +export interface PaginatedFeedResult { + activities: Activity[]; + totalCount: number; + hasMore: boolean; + nextCursor?: ActivityId; // For cursor-based pagination +} + +export interface IFeedRepository { + addActivity(activity: Activity): Promise>; + getGlobalFeed(options: FeedQueryOptions): Promise>; + findById(activityId: ActivityId): Promise>; +} diff --git a/src/modules/feeds/domain/services/FeedService.ts b/src/modules/feeds/domain/services/FeedService.ts new file mode 100644 index 00000000..d9b0141b --- /dev/null +++ b/src/modules/feeds/domain/services/FeedService.ts @@ -0,0 +1,84 @@ +import { Result, ok, err } from '../../../../shared/core/Result'; +import { DomainService } from '../../../../shared/domain/DomainService'; +import { Activity } from '../Activity'; +import { IFeedRepository } from '../IFeedRepository'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { CardId } from '../../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; + +export class FeedServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeedServiceError'; + } +} + +export class FeedService implements DomainService { + constructor(private feedRepository: IFeedRepository) {} + + async addCardAddedToLibraryActivity( + actorId: CuratorId, + cardId: CardId, + cardTitle?: string, + cardUrl?: string, + ): Promise> { + try { + const activityResult = Activity.createCardAddedToLibrary( + actorId, + cardId, + cardTitle, + cardUrl, + ); + + if (activityResult.isErr()) { + return err(new FeedServiceError(activityResult.error.message)); + } + + const activity = activityResult.value; + const saveResult = await this.feedRepository.addActivity(activity); + + if (saveResult.isErr()) { + return err(new FeedServiceError(`Failed to save activity: ${saveResult.error.message}`)); + } + + return ok(activity); + } catch (error) { + return err(new FeedServiceError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`)); + } + } + + async addCardAddedToCollectionActivity( + actorId: CuratorId, + cardId: CardId, + collectionIds: CollectionId[], + collectionNames: string[], + cardTitle?: string, + cardUrl?: string, + ): Promise> { + try { + const activityResult = Activity.createCardAddedToCollection( + actorId, + cardId, + collectionIds, + collectionNames, + cardTitle, + cardUrl, + ); + + if (activityResult.isErr()) { + return err(new FeedServiceError(activityResult.error.message)); + } + + const activity = activityResult.value; + const saveResult = await this.feedRepository.addActivity(activity); + + if (saveResult.isErr()) { + return err(new FeedServiceError(`Failed to save activity: ${saveResult.error.message}`)); + } + + return ok(activity); + } catch (error) { + return err(new FeedServiceError(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`)); + } + } +} diff --git a/src/modules/feeds/domain/value-objects/ActivityId.ts b/src/modules/feeds/domain/value-objects/ActivityId.ts new file mode 100644 index 00000000..26fe2fd5 --- /dev/null +++ b/src/modules/feeds/domain/value-objects/ActivityId.ts @@ -0,0 +1,32 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +interface ActivityIdProps { + value: UniqueEntityID; +} + +export class ActivityId extends ValueObject { + getStringValue(): string { + return this.props.value.toString(); + } + + getValue(): UniqueEntityID { + return this.props.value; + } + + private constructor(value: UniqueEntityID) { + super({ value }); + } + + public static create(id?: UniqueEntityID): Result { + return ok(new ActivityId(id || new UniqueEntityID())); + } + + public static createFromString(id: string): Result { + if (!id || id.trim().length === 0) { + return err(new Error('Activity ID cannot be empty')); + } + return ok(new ActivityId(new UniqueEntityID(id))); + } +} diff --git a/src/modules/feeds/domain/value-objects/ActivityType.ts b/src/modules/feeds/domain/value-objects/ActivityType.ts new file mode 100644 index 00000000..ee30cd02 --- /dev/null +++ b/src/modules/feeds/domain/value-objects/ActivityType.ts @@ -0,0 +1,36 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +export enum ActivityTypeEnum { + CARD_ADDED_TO_LIBRARY = 'CARD_ADDED_TO_LIBRARY', + CARD_ADDED_TO_COLLECTION = 'CARD_ADDED_TO_COLLECTION', +} + +interface ActivityTypeProps { + value: ActivityTypeEnum; +} + +export class ActivityType extends ValueObject { + get value(): ActivityTypeEnum { + return this.props.value; + } + + private constructor(props: ActivityTypeProps) { + super(props); + } + + public static create(type: ActivityTypeEnum): Result { + if (!Object.values(ActivityTypeEnum).includes(type)) { + return err(new Error(`Invalid activity type: ${type}`)); + } + return ok(new ActivityType({ value: type })); + } + + public static cardAddedToLibrary(): Result { + return this.create(ActivityTypeEnum.CARD_ADDED_TO_LIBRARY); + } + + public static cardAddedToCollection(): Result { + return this.create(ActivityTypeEnum.CARD_ADDED_TO_COLLECTION); + } +}