diff --git a/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts index 5b781b0c..129261c2 100644 --- a/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts +++ b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts @@ -5,6 +5,14 @@ import { NotificationService } from '../../domain/services/NotificationService'; import { IConnectionRepository } from '../../../cards/domain/IConnectionRepository'; import { ICardQueryRepository } from '../../../cards/domain/ICardQueryRepository'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { NotificationUrlParser } from '../services/NotificationUrlParser'; +import { EnvironmentConfigService } from '../../../../shared/infrastructure/config/EnvironmentConfigService'; +import { IUserRepository } from '../../../user/domain/repositories/IUserRepository'; +import { IIdentityResolutionService } from '../../../atproto/domain/services/IIdentityResolutionService'; +import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; +import { IAtUriResolutionService } from '../../../cards/domain/services/IAtUriResolutionService'; +import { DIDOrHandle } from '../../../atproto/domain/DIDOrHandle'; +import { DID } from '../../../atproto/domain/DID'; export class ConnectionCreatedEventHandler implements IEventHandler @@ -13,6 +21,11 @@ export class ConnectionCreatedEventHandler private notificationService: NotificationService, private connectionRepository: IConnectionRepository, private cardQueryRepository: ICardQueryRepository, + private configService: EnvironmentConfigService, + private userRepository: IUserRepository, + private identityResolutionService: IIdentityResolutionService, + private collectionRepository: ICollectionRepository, + private atUriResolutionService: IAtUriResolutionService, ) {} async handle(event: ConnectionCreatedEvent): Promise> { @@ -44,6 +57,40 @@ export class ConnectionCreatedEventHandler const sourceUrl = connection.source.url.value; const targetUrl = connection.target.url.value; const curatorId = connection.curatorId; + const appUrl = this.configService.getAppConfig().appUrl; + + // Track content owners to exclude from generic notifications + const contentOwnerDids = new Set(); + + // Parse URLs to detect Bluesky posts or Semble collections + const parsedSourceUrl = + NotificationUrlParser.extractMentionedEntityFromUrl(sourceUrl, appUrl); + const parsedTargetUrl = + NotificationUrlParser.extractMentionedEntityFromUrl(targetUrl, appUrl); + + // Handle source URL if it's a Bluesky post or Semble collection + if (parsedSourceUrl) { + const sourceOwnerDid = await this.handleUrlOwnerNotification( + parsedSourceUrl, + event.connectionId, + curatorId, + ); + if (sourceOwnerDid) { + contentOwnerDids.add(sourceOwnerDid); + } + } + + // Handle target URL if it's a Bluesky post or Semble collection + if (parsedTargetUrl) { + const targetOwnerDid = await this.handleUrlOwnerNotification( + parsedTargetUrl, + event.connectionId, + curatorId, + ); + if (targetOwnerDid) { + contentOwnerDids.add(targetOwnerDid); + } + } // Find all users who have either URL in their library const recipientUserIds = new Set(); @@ -59,8 +106,11 @@ export class ConnectionCreatedEventHandler if (sourceLibrariesResult.items) { sourceLibrariesResult.items.forEach((library) => { - // Exclude the curator who created the connection - if (library.userId !== curatorId.value) { + // Exclude the curator who created the connection and content owners + if ( + library.userId !== curatorId.value && + !contentOwnerDids.has(library.userId) + ) { recipientUserIds.add(library.userId); } }); @@ -77,8 +127,11 @@ export class ConnectionCreatedEventHandler if (targetLibrariesResult.items) { targetLibrariesResult.items.forEach((library) => { - // Exclude the curator who created the connection - if (library.userId !== curatorId.value) { + // Exclude the curator who created the connection and content owners + if ( + library.userId !== curatorId.value && + !contentOwnerDids.has(library.userId) + ) { recipientUserIds.add(library.userId); } }); @@ -125,4 +178,200 @@ export class ConnectionCreatedEventHandler return err(error as Error); } } + + /** + * Handle notification for URL owner (Bluesky post or Semble collection) + * Returns the owner DID if successful, null otherwise + */ + private async handleUrlOwnerNotification( + parsedUrl: { + type: 'BLUESKY_POST' | 'SEMBLE_COLLECTION'; + handleOrDid: string; + postId?: string; + rkey?: string; + }, + connectionId: any, + curatorId: CuratorId, + ): Promise { + try { + if (parsedUrl.type === 'BLUESKY_POST') { + // Extract handle/DID and resolve to DID + const didOrHandleResult = DIDOrHandle.create(parsedUrl.handleOrDid); + if (didOrHandleResult.isErr()) { + console.warn( + `Failed to parse DID/handle from Bluesky URL: ${parsedUrl.handleOrDid}`, + ); + return null; + } + + const didOrHandle = didOrHandleResult.value; + + // Resolve to DID if it's a handle + let did: DID; + if (didOrHandle.isDID) { + did = didOrHandle.getDID()!; + } else { + const resolveResult = + await this.identityResolutionService.resolveToDID(didOrHandle); + if (resolveResult.isErr()) { + console.warn( + `Failed to resolve handle to DID: ${parsedUrl.handleOrDid}`, + ); + return null; + } + did = resolveResult.value; + } + + const recipientDid = did.value; + + // Check if user exists in our system + const recipientDidResult = DID.create(recipientDid); + if (recipientDidResult.isErr()) { + console.warn(`Invalid recipient DID: ${recipientDid}`); + return null; + } + + const userResult = await this.userRepository.findByDID( + recipientDidResult.value, + ); + if (userResult.isErr() || !userResult.value) { + // User doesn't exist in our system, don't create notification + return null; + } + + // Don't create notification if user is connecting their own post + if (recipientDid === curatorId.value) { + return null; + } + + const recipientIdResult = CuratorId.create(recipientDid); + if (recipientIdResult.isErr()) { + console.error('Invalid recipient ID:', recipientIdResult.error); + return null; + } + + // Create USER_CONNECTED_YOUR_POST notification + const notificationResult = + await this.notificationService.createUserConnectedYourPostNotification( + recipientIdResult.value, + curatorId, + connectionId, + ); + + if (notificationResult.isErr()) { + console.error( + 'Failed to create post connection notification:', + notificationResult.error, + ); + return null; + } + + return recipientDid; + } else if (parsedUrl.type === 'SEMBLE_COLLECTION') { + // Resolve handle to DID first + const didOrHandleResult = DIDOrHandle.create(parsedUrl.handleOrDid); + if (didOrHandleResult.isErr()) { + console.warn( + `Failed to parse DID/handle from Semble URL: ${parsedUrl.handleOrDid}`, + ); + return null; + } + + const didOrHandle = didOrHandleResult.value; + + // Resolve to DID if it's a handle + let collectionAuthorDid: DID; + if (didOrHandle.isDID) { + collectionAuthorDid = didOrHandle.getDID()!; + } else { + const resolveResult = + await this.identityResolutionService.resolveToDID(didOrHandle); + if (resolveResult.isErr()) { + console.warn( + `Failed to resolve handle to DID: ${parsedUrl.handleOrDid}`, + ); + return null; + } + collectionAuthorDid = resolveResult.value; + } + + // Build AT URI: at://{did}/network.cosmik.local.collection/{rkey} + const atprotoCollection = + this.configService.getAtProtoCollections().collection; + const atUri = `at://${collectionAuthorDid.value}/${atprotoCollection}/${parsedUrl.rkey}`; + + // Resolve AT URI to get CollectionId + const collectionIdResult = + await this.atUriResolutionService.resolveCollectionId(atUri); + + if (collectionIdResult.isErr() || !collectionIdResult.value) { + console.warn(`Collection not found for AT URI: ${atUri}`); + return null; + } + + const collectionId = collectionIdResult.value; + + // Fetch collection to get author DID + const collectionResult = + await this.collectionRepository.findById(collectionId); + + if (collectionResult.isErr() || !collectionResult.value) { + console.warn(`Collection not found for ID: ${collectionId}`); + return null; + } + + const collection = collectionResult.value; + const recipientDid = collection.authorId.value; + + // Check if user exists in our system + const recipientDidResult = DID.create(recipientDid); + if (recipientDidResult.isErr()) { + console.warn(`Invalid recipient DID: ${recipientDid}`); + return null; + } + + const userResult = await this.userRepository.findByDID( + recipientDidResult.value, + ); + if (userResult.isErr() || !userResult.value) { + // User doesn't exist in our system, don't create notification + return null; + } + + // Don't create notification if user is connecting their own collection + if (recipientDid === curatorId.value) { + return null; + } + + const recipientIdResult = CuratorId.create(recipientDid); + if (recipientIdResult.isErr()) { + console.error('Invalid recipient ID:', recipientIdResult.error); + return null; + } + + // Create USER_CONNECTED_YOUR_COLLECTION notification + const notificationResult = + await this.notificationService.createUserConnectedYourCollectionNotification( + recipientIdResult.value, + curatorId, + connectionId, + ); + + if (notificationResult.isErr()) { + console.error( + 'Failed to create collection connection notification:', + notificationResult.error, + ); + return null; + } + + return recipientDid; + } + + return null; + } catch (error) { + console.error('Error in handleUrlOwnerNotification:', error); + return null; + } + } } diff --git a/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts b/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts index c58cec76..0a3dbb6d 100644 --- a/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts +++ b/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts @@ -48,12 +48,28 @@ export interface CreateUserConnectedYourUrlNotificationDTO { connectionId: string; } +export interface CreateUserConnectedYourPostNotificationDTO { + type: NotificationType.USER_CONNECTED_YOUR_POST; + recipientUserId: string; + actorUserId: string; + connectionId: string; +} + +export interface CreateUserConnectedYourCollectionNotificationDTO { + type: NotificationType.USER_CONNECTED_YOUR_COLLECTION; + recipientUserId: string; + actorUserId: string; + connectionId: string; +} + export type CreateNotificationDTO = | CreateUserAddedYourCardNotificationDTO | CreateUserAddedToYourCollectionNotificationDTO | CreateUserAddedYourBskyPostNotificationDTO | CreateUserAddedYourCollectionNotificationDTO - | CreateUserConnectedYourUrlNotificationDTO; + | CreateUserConnectedYourUrlNotificationDTO + | CreateUserConnectedYourPostNotificationDTO + | CreateUserConnectedYourCollectionNotificationDTO; export interface CreateNotificationResponseDTO { notificationId: string; @@ -274,6 +290,46 @@ export class CreateNotificationUseCase actorId, connectionIdResult.value, ); + } else if (request.type === NotificationType.USER_CONNECTED_YOUR_POST) { + // Validate connection ID + const connectionIdResult = ConnectionId.createFromString( + request.connectionId, + ); + if (connectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid connection ID: ${connectionIdResult.error.message}`, + ), + ); + } + + notificationResult = + await this.notificationService.createUserConnectedYourPostNotification( + recipientId, + actorId, + connectionIdResult.value, + ); + } else if ( + request.type === NotificationType.USER_CONNECTED_YOUR_COLLECTION + ) { + // Validate connection ID + const connectionIdResult = ConnectionId.createFromString( + request.connectionId, + ); + if (connectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid connection ID: ${connectionIdResult.error.message}`, + ), + ); + } + + notificationResult = + await this.notificationService.createUserConnectedYourCollectionNotification( + recipientId, + actorId, + connectionIdResult.value, + ); } else { // Type exhaustiveness check const _exhaustiveCheck: never = request; diff --git a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts index 73f47ac6..ad074bb8 100644 --- a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts +++ b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts @@ -127,7 +127,9 @@ export class GetMyNotificationsUseCase const connectionNotifications = notifications.filter( (n) => (n as any).connectionId !== undefined && - n.type === 'USER_CONNECTED_YOUR_URL', + (n.type === 'USER_CONNECTED_YOUR_URL' || + n.type === 'USER_CONNECTED_YOUR_POST' || + n.type === 'USER_CONNECTED_YOUR_COLLECTION'), ); const connectionMap = new Map(); @@ -216,7 +218,9 @@ export class GetMyNotificationsUseCase const metadata = notification as any; if ( metadata.connectionId !== undefined && - notification.type === 'USER_CONNECTED_YOUR_URL' + (notification.type === 'USER_CONNECTED_YOUR_URL' || + notification.type === 'USER_CONNECTED_YOUR_POST' || + notification.type === 'USER_CONNECTED_YOUR_COLLECTION') ) { // Get the connection from the pre-fetched map const connection = connectionMap.get(metadata.connectionId); diff --git a/src/modules/notifications/domain/Notification.ts b/src/modules/notifications/domain/Notification.ts index c99942b4..a3c0216d 100644 --- a/src/modules/notifications/domain/Notification.ts +++ b/src/modules/notifications/domain/Notification.ts @@ -251,6 +251,50 @@ export class Notification extends AggregateRoot { }); } + public static createUserConnectedYourPost( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Result { + const typeResult = NotificationType.userConnectedYourPost(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: ConnectionNotificationMetadata = { + connectionId: connectionId.getStringValue(), + }; + + return this.create({ + recipientUserId, + actorUserId, + type: typeResult.value, + metadata: metadata as any, + }); + } + + public static createUserConnectedYourCollection( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Result { + const typeResult = NotificationType.userConnectedYourCollection(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: ConnectionNotificationMetadata = { + connectionId: connectionId.getStringValue(), + }; + + return this.create({ + recipientUserId, + actorUserId, + type: typeResult.value, + metadata: metadata as any, + }); + } + public markAsRead(): void { this.props.read = true; this.props.updatedAt = new Date(); diff --git a/src/modules/notifications/domain/services/NotificationService.ts b/src/modules/notifications/domain/services/NotificationService.ts index 8143e4e0..6d1c27a3 100644 --- a/src/modules/notifications/domain/services/NotificationService.ts +++ b/src/modules/notifications/domain/services/NotificationService.ts @@ -358,4 +358,100 @@ export class NotificationService implements DomainService { ); } } + + async createUserConnectedYourPostNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Promise> { + try { + // Don't create notification if user is connecting their own post + if (recipientUserId.equals(actorUserId)) { + return err( + new NotificationServiceError( + 'Cannot notify user about their own action', + ), + ); + } + + const notificationResult = Notification.createUserConnectedYourPost( + recipientUserId, + actorUserId, + connectionId, + ); + + if (notificationResult.isErr()) { + return err( + new NotificationServiceError(notificationResult.error.message), + ); + } + + const notification = notificationResult.value; + const saveResult = await this.notificationRepository.save(notification); + + if (saveResult.isErr()) { + return err( + new NotificationServiceError( + `Failed to save notification: ${saveResult.error.message}`, + ), + ); + } + + return ok(notification); + } catch (error) { + return err( + new NotificationServiceError( + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } + + async createUserConnectedYourCollectionNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Promise> { + try { + // Don't create notification if user is connecting their own collection + if (recipientUserId.equals(actorUserId)) { + return err( + new NotificationServiceError( + 'Cannot notify user about their own action', + ), + ); + } + + const notificationResult = Notification.createUserConnectedYourCollection( + recipientUserId, + actorUserId, + connectionId, + ); + + if (notificationResult.isErr()) { + return err( + new NotificationServiceError(notificationResult.error.message), + ); + } + + const notification = notificationResult.value; + const saveResult = await this.notificationRepository.save(notification); + + if (saveResult.isErr()) { + return err( + new NotificationServiceError( + `Failed to save notification: ${saveResult.error.message}`, + ), + ); + } + + return ok(notification); + } catch (error) { + return err( + new NotificationServiceError( + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } } diff --git a/src/modules/notifications/domain/value-objects/NotificationType.ts b/src/modules/notifications/domain/value-objects/NotificationType.ts index 0af34f42..377538ea 100644 --- a/src/modules/notifications/domain/value-objects/NotificationType.ts +++ b/src/modules/notifications/domain/value-objects/NotificationType.ts @@ -49,4 +49,12 @@ export class NotificationType extends ValueObject { public static userConnectedYourUrl(): Result { return this.create(NotificationTypeEnum.USER_CONNECTED_YOUR_URL); } + + public static userConnectedYourPost(): Result { + return this.create(NotificationTypeEnum.USER_CONNECTED_YOUR_POST); + } + + public static userConnectedYourCollection(): Result { + return this.create(NotificationTypeEnum.USER_CONNECTED_YOUR_COLLECTION); + } } diff --git a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts index f52bfee5..5262efc8 100644 --- a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts +++ b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts @@ -114,6 +114,11 @@ export class InMemoryEventWorkerProcess implements IProcess { services.notificationService, repositories.connectionRepository, repositories.cardQueryRepository, + this.configService, + repositories.userRepository, + services.identityResolutionService, + repositories.collectionRepository, + repositories.atUriResolutionService, ); const connectionRemovedHandler = new ConnectionRemovedEventHandler( diff --git a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts index e07d26b7..7e1229e9 100644 --- a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts +++ b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts @@ -93,6 +93,11 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { services.notificationService, repositories.connectionRepository, repositories.cardQueryRepository, + this.configService, + repositories.userRepository, + services.identityResolutionService, + repositories.collectionRepository, + repositories.atUriResolutionService, ); const connectionRemovedHandler = new ConnectionRemovedEventHandler( diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 48602176..80ab16a9 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -373,6 +373,8 @@ export enum NotificationType { USER_FOLLOWED_YOU = 'USER_FOLLOWED_YOU', USER_FOLLOWED_YOUR_COLLECTION = 'USER_FOLLOWED_YOUR_COLLECTION', USER_CONNECTED_YOUR_URL = 'USER_CONNECTED_YOUR_URL', + USER_CONNECTED_YOUR_POST = 'USER_CONNECTED_YOUR_POST', + USER_CONNECTED_YOUR_COLLECTION = 'USER_CONNECTED_YOUR_COLLECTION', } export interface BaseNotificationItem { @@ -404,7 +406,10 @@ export interface FollowNotificationItem extends BaseNotificationItem { export interface ConnectionCreatedNotificationItem extends BaseNotificationItem { - type: NotificationType.USER_CONNECTED_YOUR_URL; + type: + | NotificationType.USER_CONNECTED_YOUR_URL + | NotificationType.USER_CONNECTED_YOUR_POST + | NotificationType.USER_CONNECTED_YOUR_COLLECTION; connection: ConnectionWithSourceAndTarget; } diff --git a/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx b/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx index f0a92717..db043051 100644 --- a/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx +++ b/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx @@ -159,6 +159,18 @@ export default function NotificationActivityStatus(props: Props) { {userName} connected a card in your library ); + case NotificationType.USER_CONNECTED_YOUR_POST: + return ( + + {userName} connected your post + + ); + case NotificationType.USER_CONNECTED_YOUR_COLLECTION: + return ( + + {userName} connected your collection + + ); default: return ( diff --git a/src/webapp/features/notifications/lib/utils/icon.tsx b/src/webapp/features/notifications/lib/utils/icon.tsx index c8ba9a6e..a4504592 100644 --- a/src/webapp/features/notifications/lib/utils/icon.tsx +++ b/src/webapp/features/notifications/lib/utils/icon.tsx @@ -24,6 +24,10 @@ export const getNotificationTypeIcon = ( return FaUserPlus; case NotificationType.USER_CONNECTED_YOUR_URL: return BiLink; + case NotificationType.USER_CONNECTED_YOUR_POST: + return BiLink; + case NotificationType.USER_CONNECTED_YOUR_COLLECTION: + return BiLink; default: return null; } diff --git a/src/webapp/features/notifications/lib/utils/index.ts b/src/webapp/features/notifications/lib/utils/index.ts index c986511e..85692efd 100644 --- a/src/webapp/features/notifications/lib/utils/index.ts +++ b/src/webapp/features/notifications/lib/utils/index.ts @@ -9,7 +9,10 @@ import { NotificationType } from '@/api-client'; export type NotificationKind = 'connection' | 'follow' | 'cardCollection'; type ClassifiedNotification = - | { kind: 'connection'; item: ConnectionCreatedNotificationItem } + | { + kind: 'connection'; + item: ConnectionCreatedNotificationItem; + } | { kind: 'follow'; item: FollowNotificationItem } | { kind: 'cardCollection'; item: CardCollectionNotificationItem }; @@ -18,6 +21,8 @@ export const classifyNotification = ( ): ClassifiedNotification => { switch (item.type) { case NotificationType.USER_CONNECTED_YOUR_URL: + case NotificationType.USER_CONNECTED_YOUR_POST: + case NotificationType.USER_CONNECTED_YOUR_COLLECTION: return { kind: 'connection', item }; case NotificationType.USER_FOLLOWED_YOU: case NotificationType.USER_FOLLOWED_YOUR_COLLECTION: