diff --git a/src/modules/atproto/infrastructure/lexicon/lexicons.ts b/src/modules/atproto/infrastructure/lexicon/lexicons.ts index b4907d44..b2b65c60 100644 --- a/src/modules/atproto/infrastructure/lexicon/lexicons.ts +++ b/src/modules/atproto/infrastructure/lexicon/lexicons.ts @@ -316,6 +316,34 @@ export const schemaDict = { }, }, }, + NetworkCosmikFollow: { + lexicon: 1, + id: 'network.cosmik.follow', + description: 'A record representing a follow relationship.', + defs: { + main: { + type: 'record', + description: 'A record representing a follow of a user or collection.', + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + description: + 'DID of the user being followed, or AT URI of the collection being followed', + }, + createdAt: { + type: 'string', + format: 'datetime', + description: 'Timestamp when this follow was created.', + }, + }, + }, + }, + }, + }, ComAtprotoRepoStrongRef: { lexicon: 1, id: 'com.atproto.repo.strongRef', @@ -963,6 +991,7 @@ export const ids = { NetworkCosmikCollectionLink: 'network.cosmik.collectionLink', NetworkCosmikCollectionLinkRemoval: 'network.cosmik.collectionLinkRemoval', NetworkCosmikDefs: 'network.cosmik.defs', + NetworkCosmikFollow: 'network.cosmik.follow', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', AtMarginAnnotation: 'at.margin.annotation', AtMarginBookmark: 'at.margin.bookmark', diff --git a/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/follow.ts b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/follow.ts new file mode 100644 index 00000000..f2b0a8b2 --- /dev/null +++ b/src/modules/atproto/infrastructure/lexicon/types/network/cosmik/follow.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon'; +import { CID } from 'multiformats/cid'; +import { validate as _validate } from '../../../lexicons'; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../util'; + +const is$typed = _is$typed, + validate = _validate; +const id = 'network.cosmik.follow'; + +export interface Record { + $type: 'network.cosmik.follow'; + /** DID of the user being followed, or AT URI of the collection being followed */ + subject: string; + /** Timestamp when this follow was created. */ + createdAt: string; + [k: string]: unknown; +} + +const hashRecord = 'main'; + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord); +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true); +} diff --git a/src/modules/atproto/infrastructure/lexicons/follow.json b/src/modules/atproto/infrastructure/lexicons/follow.json new file mode 100644 index 00000000..1359ab21 --- /dev/null +++ b/src/modules/atproto/infrastructure/lexicons/follow.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "network.cosmik.follow", + "description": "A record representing a follow relationship.", + "defs": { + "main": { + "type": "record", + "description": "A record representing a follow of a user or collection.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { + "type": "string", + "description": "DID of the user being followed, or AT URI of the collection being followed" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this follow was created." + } + } + } + } + } +} diff --git a/src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts b/src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts new file mode 100644 index 00000000..c2bb1e3e --- /dev/null +++ b/src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts @@ -0,0 +1,178 @@ +import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; +import { Follow } from '../../../user/domain/Follow'; +import { Result, ok, err } from '../../../../shared/core/Result'; +import { UseCaseError } from '../../../../shared/core/UseCaseError'; +import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; +import { IAgentService } from '../../application/IAgentService'; +import { DID } from '../../domain/DID'; +import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; +import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; +import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; + +export class ATProtoFollowPublisher implements IFollowPublisher { + constructor( + private readonly agentService: IAgentService, + private readonly followCollection: string, // 'network.cosmik.follow' + private readonly collectionRepository: ICollectionRepository, + ) {} + + async publishFollow( + follow: Follow, + ): Promise> { + try { + const followerDid = DID.create(follow.followerId.value); + if (followerDid.isErr()) { + return err( + new Error(`Invalid follower DID: ${followerDid.error.message}`), + ); + } + + const agentResult = await this.agentService.getAuthenticatedAgent( + followerDid.value, + ); + + if (agentResult.isErr()) { + // Propagate authentication errors as-is + if (agentResult.error instanceof AuthenticationError) { + return err(agentResult.error); + } + return err( + new Error( + `Failed to get authenticated agent: ${agentResult.error.message}`, + ), + ); + } + + const agent = agentResult.value; + + if (!agent) { + return err(new Error('No authenticated session found for follower')); + } + + // Determine the subject based on target type + let subject: string; + if (follow.targetType.value === 'USER') { + // For user follows, subject is the DID + subject = follow.targetId; + } else { + // For collection follows, subject is the AT URI of the published collection + const collectionIdResult = CollectionId.createFromString( + follow.targetId, + ); + if (collectionIdResult.isErr()) { + return err( + new Error( + `Invalid collection ID: ${collectionIdResult.error.message}`, + ), + ); + } + + const collectionResult = await this.collectionRepository.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr()) { + return err( + new Error( + `Failed to find collection: ${collectionResult.error.message}`, + ), + ); + } + + const collection = collectionResult.value; + if (!collection) { + return err(new Error('Collection not found')); + } + + if (!collection.publishedRecordId) { + return err( + new Error('Collection must be published before it can be followed'), + ); + } + + // Use the AT URI of the published collection + subject = collection.publishedRecordId.uri; + } + + const record = { + $type: this.followCollection, + subject: subject, + createdAt: follow.createdAt.toISOString(), + }; + + const createResult = await agent.com.atproto.repo.createRecord({ + repo: follow.followerId.value, + collection: this.followCollection, + record, + }); + + const publishedRecordId = PublishedRecordId.create({ + uri: createResult.data.uri, + cid: createResult.data.cid, + }); + + return ok(publishedRecordId); + } catch (error) { + return err( + new Error( + `Failed to publish follow: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } + + async unpublishFollow(follow: Follow): Promise> { + try { + if (!follow.publishedRecordId) { + // Already unpublished or never published + return ok(undefined); + } + + const followerDid = DID.create(follow.followerId.value); + if (followerDid.isErr()) { + return err( + new Error(`Invalid follower DID: ${followerDid.error.message}`), + ); + } + + const agentResult = await this.agentService.getAuthenticatedAgent( + followerDid.value, + ); + + if (agentResult.isErr()) { + // Propagate authentication errors as-is + if (agentResult.error instanceof AuthenticationError) { + return err(agentResult.error); + } + return err( + new Error( + `Failed to get authenticated agent: ${agentResult.error.message}`, + ), + ); + } + + const agent = agentResult.value; + + if (!agent) { + return err(new Error('No authenticated session found for follower')); + } + + // Extract rkey from AT URI (format: at://did/collection/rkey) + const uriParts = follow.publishedRecordId.uri.split('/'); + const rkey = uriParts[uriParts.length - 1]!; + + await agent.com.atproto.repo.deleteRecord({ + repo: follow.followerId.value, + collection: this.followCollection, + rkey, + }); + + return ok(undefined); + } catch (error) { + return err( + new Error( + `Failed to unpublish follow: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts b/src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts new file mode 100644 index 00000000..ae591e3a --- /dev/null +++ b/src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts @@ -0,0 +1,47 @@ +import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; +import { Follow } from '../../../user/domain/Follow'; +import { Result, ok } from '../../../../shared/core/Result'; +import { UseCaseError } from '../../../../shared/core/UseCaseError'; +import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; + +export class FakeFollowPublisher implements IFollowPublisher { + private publishedFollows: Map = new Map(); + + async publishFollow( + follow: Follow, + ): Promise> { + const key = `${follow.followerId.value}:${follow.targetId}`; + const fakeUri = `at://${follow.followerId.value}/network.cosmik.follow/${Date.now()}`; + const fakeCid = `fake-cid-${Date.now()}`; + + const recordId = PublishedRecordId.create({ + uri: fakeUri, + cid: fakeCid, + }); + + this.publishedFollows.set(key, recordId); + + return ok(recordId); + } + + async unpublishFollow(follow: Follow): Promise> { + if (!follow.publishedRecordId) { + return ok(undefined); + } + + // Find and remove from map + for (const [key, value] of this.publishedFollows.entries()) { + if (value.uri === follow.publishedRecordId.uri) { + this.publishedFollows.delete(key); + break; + } + } + + return ok(undefined); + } + + // Test helper + getPublishedFollows(): Map { + return this.publishedFollows; + } +} diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index 0bc165fb..9f489518 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -110,11 +110,12 @@ export async function createTestSchema(db: PostgresJsDatabase) { updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() )`, - // Follows table (no dependencies) + // Follows table (references published_records) sql`CREATE TABLE IF NOT EXISTS follows ( follower_id TEXT NOT NULL, target_id TEXT NOT NULL, target_type TEXT NOT NULL, + published_record_id UUID REFERENCES published_records(id), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), PRIMARY KEY (follower_id, target_id, target_type) )`, diff --git a/src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts b/src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts new file mode 100644 index 00000000..4dc10526 --- /dev/null +++ b/src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts @@ -0,0 +1,111 @@ +import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; +import { UserFollowedTargetEvent } from '../../../user/domain/events/UserFollowedTargetEvent'; +import { Result, ok, err } from '../../../../shared/core/Result'; +import { NotificationService } from '../../domain/services/NotificationService'; +import { IUserRepository } from '../../../user/domain/repositories/IUserRepository'; +import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; + +export class UserFollowedTargetEventHandler + implements IEventHandler +{ + constructor( + private notificationService: NotificationService, + private userRepository: IUserRepository, + private collectionRepository: ICollectionRepository, + ) {} + + async handle(event: UserFollowedTargetEvent): Promise> { + try { + const actorIdResult = CuratorId.create(event.followerId.value); + if (actorIdResult.isErr()) { + console.error('Invalid actor ID:', actorIdResult.error); + return err(actorIdResult.error); + } + const actorId = actorIdResult.value; + + if (event.targetType.value === 'USER') { + // User followed another user - notify the followed user + const recipientIdResult = CuratorId.create(event.targetId); + if (recipientIdResult.isErr()) { + console.error('Invalid recipient ID:', recipientIdResult.error); + return err(recipientIdResult.error); + } + const recipientId = recipientIdResult.value; + + // Skip if user is following themselves (shouldn't happen due to validation) + if (actorId.equals(recipientId)) { + return ok(undefined); + } + + const notificationResult = + await this.notificationService.createUserFollowedYouNotification( + recipientId, + actorId, + ); + + if (notificationResult.isErr()) { + console.error( + 'Failed to create user followed notification:', + notificationResult.error, + ); + return err(notificationResult.error); + } + } else if (event.targetType.value === 'COLLECTION') { + // User followed a collection - notify the collection author + const collectionIdResult = CollectionId.createFromString( + event.targetId, + ); + if (collectionIdResult.isErr()) { + console.error('Invalid collection ID:', collectionIdResult.error); + return err(collectionIdResult.error); + } + const collectionId = collectionIdResult.value; + + const collectionResult = + await this.collectionRepository.findById(collectionId); + if (collectionResult.isErr()) { + console.error('Failed to find collection:', collectionResult.error); + return err(collectionResult.error); + } + + const collection = collectionResult.value; + if (!collection) { + console.warn( + 'Collection not found for notification:', + event.targetId, + ); + return ok(undefined); + } + + const recipientId = collection.authorId; + + // Skip if user is following their own collection + if (actorId.equals(recipientId)) { + return ok(undefined); + } + + const notificationResult = + await this.notificationService.createUserFollowedYourCollectionNotification( + recipientId, + actorId, + collectionId, + ); + + if (notificationResult.isErr()) { + console.error( + 'Failed to create collection followed notification:', + notificationResult.error, + ); + return err(notificationResult.error); + } + } + + return ok(undefined); + } catch (error) { + console.error('Error handling UserFollowedTargetEvent:', error); + return err(error as Error); + } + } +} diff --git a/src/modules/notifications/domain/Notification.ts b/src/modules/notifications/domain/Notification.ts index 504df58d..872b5421 100644 --- a/src/modules/notifications/domain/Notification.ts +++ b/src/modules/notifications/domain/Notification.ts @@ -12,6 +12,11 @@ export interface NotificationMetadata { collectionIds?: string[]; } +export interface FollowNotificationMetadata { + targetType: 'USER' | 'COLLECTION'; + targetId?: string; // Collection ID if applicable +} + interface NotificationProps { recipientUserId: CuratorId; actorUserId: CuratorId; @@ -127,6 +132,50 @@ export class Notification extends AggregateRoot { }); } + public static createUserFollowedYou( + recipientUserId: CuratorId, + actorUserId: CuratorId, + ): Result { + const typeResult = NotificationType.userFollowedYou(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: FollowNotificationMetadata = { + targetType: 'USER', + }; + + return this.create({ + recipientUserId, + actorUserId, + type: typeResult.value, + metadata: metadata as any, + }); + } + + public static createUserFollowedYourCollection( + recipientUserId: CuratorId, + actorUserId: CuratorId, + collectionId: CollectionId, + ): Result { + const typeResult = NotificationType.userFollowedYourCollection(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: FollowNotificationMetadata = { + targetType: 'COLLECTION', + targetId: collectionId.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 7bb5c11f..43354482 100644 --- a/src/modules/notifications/domain/services/NotificationService.ts +++ b/src/modules/notifications/domain/services/NotificationService.ts @@ -115,4 +115,98 @@ export class NotificationService implements DomainService { ); } } + + async createUserFollowedYouNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + ): Promise> { + try { + // Don't create notification if user is following themselves (shouldn't happen) + if (recipientUserId.equals(actorUserId)) { + return err( + new NotificationServiceError( + 'Cannot notify user about their own action', + ), + ); + } + + const notificationResult = Notification.createUserFollowedYou( + recipientUserId, + actorUserId, + ); + + 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 createUserFollowedYourCollectionNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + collectionId: CollectionId, + ): Promise> { + try { + // Don't create notification if user is following their own collection + if (recipientUserId.equals(actorUserId)) { + return err( + new NotificationServiceError( + 'Cannot notify user about their own action', + ), + ); + } + + const notificationResult = Notification.createUserFollowedYourCollection( + recipientUserId, + actorUserId, + collectionId, + ); + + 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 39ff6e85..29ca52b8 100644 --- a/src/modules/notifications/domain/value-objects/NotificationType.ts +++ b/src/modules/notifications/domain/value-objects/NotificationType.ts @@ -37,4 +37,12 @@ export class NotificationType extends ValueObject { public static userAddedToYourCollection(): Result { return this.create(NotificationTypeEnum.USER_ADDED_TO_YOUR_COLLECTION); } + + public static userFollowedYou(): Result { + return this.create(NotificationTypeEnum.USER_FOLLOWED_YOU); + } + + public static userFollowedYourCollection(): Result { + return this.create(NotificationTypeEnum.USER_FOLLOWED_YOUR_COLLECTION); + } } diff --git a/src/modules/user/application/ports/IFollowPublisher.ts b/src/modules/user/application/ports/IFollowPublisher.ts new file mode 100644 index 00000000..b9203208 --- /dev/null +++ b/src/modules/user/application/ports/IFollowPublisher.ts @@ -0,0 +1,24 @@ +import { Result } from '../../../../shared/core/Result'; +import { UseCaseError } from '../../../../shared/core/UseCaseError'; +import { Follow } from '../../domain/Follow'; +import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; + +export interface IFollowPublisher { + /** + * Publish a follow relationship to AT Protocol. + * + * @param follow - The Follow domain object to publish + * @returns Published record ID (AT URI + CID) + */ + publishFollow( + follow: Follow, + ): Promise>; + + /** + * Unpublish (delete) a follow relationship from AT Protocol. + * + * @param follow - The Follow domain object with publishedRecordId to unpublish + * @returns Success or error + */ + unpublishFollow(follow: Follow): Promise>; +} diff --git a/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts b/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts new file mode 100644 index 00000000..0d8c79ab --- /dev/null +++ b/src/modules/user/application/useCases/commands/FollowTargetUseCase.ts @@ -0,0 +1,209 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { BaseUseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IUserRepository } from '../../../domain/repositories/IUserRepository'; +import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; +import { IFollowPublisher } from '../../ports/IFollowPublisher'; +import { DID } from '../../../domain/value-objects/DID'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; +import { Follow } from '../../../domain/Follow'; +import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export interface FollowTargetDTO { + followerId: string; // DID + targetId: string; // DID or Collection UUID + targetType: 'USER' | 'COLLECTION'; +} + +export interface FollowTargetResponseDTO { + followId: string; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class FollowTargetUseCase extends BaseUseCase< + FollowTargetDTO, + Result +> { + constructor( + private followsRepository: IFollowsRepository, + private userRepository: IUserRepository, + private collectionRepository: ICollectionRepository, + private followPublisher: IFollowPublisher, + eventPublisher: IEventPublisher, + ) { + super(eventPublisher); + } + + async execute( + request: FollowTargetDTO, + ): Promise< + Result + > { + try { + // 1. Validate followerId (create DID value object) + const followerDidResult = DID.create(request.followerId); + if (followerDidResult.isErr()) { + return err( + new ValidationError( + `Invalid follower ID: ${followerDidResult.error.message}`, + ), + ); + } + const followerDid = followerDidResult.value; + + // 2. Validate targetType + const targetTypeResult = FollowTargetType.create( + request.targetType as any, + ); + if (targetTypeResult.isErr()) { + return err( + new ValidationError( + `Invalid target type: ${targetTypeResult.error.message}`, + ), + ); + } + const targetType = targetTypeResult.value; + + // 3. Prevent self-follows (only for USER type) + if ( + targetType.value === 'USER' && + request.followerId === request.targetId + ) { + return err(new ValidationError('Users cannot follow themselves')); + } + + // 4. Validate target exists + if (targetType.value === 'USER') { + const targetDidResult = DID.create(request.targetId); + if (targetDidResult.isErr()) { + return err( + new ValidationError( + `Invalid target ID: ${targetDidResult.error.message}`, + ), + ); + } + + const userResult = await this.userRepository.findByDID( + targetDidResult.value, + ); + if (userResult.isErr()) { + return err(AppError.UnexpectedError.create(userResult.error)); + } + + if (!userResult.value) { + return err(new ValidationError('Target user not found')); + } + } else if (targetType.value === 'COLLECTION') { + const collectionIdResult = CollectionId.createFromString( + request.targetId, + ); + if (collectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid collection ID: ${collectionIdResult.error.message}`, + ), + ); + } + + const collectionResult = await this.collectionRepository.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr()) { + return err(AppError.UnexpectedError.create(collectionResult.error)); + } + + if (!collectionResult.value) { + return err(new ValidationError('Target collection not found')); + } + } + + // 5. Check if already following (idempotent) + const existingFollowResult = + await this.followsRepository.findByFollowerAndTarget( + request.followerId, + request.targetId, + targetType, + ); + + if (existingFollowResult.isErr()) { + return err(AppError.UnexpectedError.create(existingFollowResult.error)); + } + + if (existingFollowResult.value) { + // Already following - return success with existing follow ID + return ok({ + followId: existingFollowResult.value.followId.toString(), + }); + } + + // 6. Create Follow aggregate (does NOT raise event yet) + const followResult = Follow.createNew( + followerDid, + request.targetId, + targetType, + ); + + if (followResult.isErr()) { + return err(new ValidationError(followResult.error.message)); + } + + let follow = followResult.value; + + // 7. Publish to AT Protocol BEFORE saving + const publishResult = await this.followPublisher.publishFollow(follow); + + if (publishResult.isErr()) { + // Propagate authentication errors + if (publishResult.error instanceof AuthenticationError) { + return err(publishResult.error); + } + if (publishResult.error instanceof AppError.UnexpectedError) { + return err(publishResult.error); + } + return err(new ValidationError(publishResult.error.message)); + } + + // 8. Mark follow as published with the returned publishedRecordId + const publishedRecordId = publishResult.value; + follow.markAsPublished(publishedRecordId); + + // 9. Save to repository with publishedRecordId + const saveResult = await this.followsRepository.save(follow); + if (saveResult.isErr()) { + return err(AppError.UnexpectedError.create(saveResult.error)); + } + + // 10. Raise UserFollowedTargetEvent (now that publish succeeded) + const eventResult = follow.raiseFollowedEvent(); + if (eventResult.isErr()) { + console.error('Failed to raise followed event:', eventResult.error); + } + + // 11. Publish domain events + const publishEventsResult = await this.publishEventsForAggregate(follow); + if (publishEventsResult.isErr()) { + console.error( + 'Failed to publish domain events:', + publishEventsResult.error, + ); + // Don't fail the operation + } + + // 12. Return success + return ok({ + followId: follow.followId.toString(), + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts b/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts new file mode 100644 index 00000000..db19791f --- /dev/null +++ b/src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts @@ -0,0 +1,128 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { BaseUseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; +import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; +import { IFollowPublisher } from '../../ports/IFollowPublisher'; +import { DID } from '../../../domain/value-objects/DID'; +import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; + +export interface UnfollowTargetDTO { + followerId: string; // DID + targetId: string; // DID or Collection UUID + targetType: 'USER' | 'COLLECTION'; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class UnfollowTargetUseCase extends BaseUseCase< + UnfollowTargetDTO, + Result +> { + constructor( + private followsRepository: IFollowsRepository, + private followPublisher: IFollowPublisher, + eventPublisher: IEventPublisher, + ) { + super(eventPublisher); + } + + async execute( + request: UnfollowTargetDTO, + ): Promise> { + try { + // 1. Validate followerId (create DID value object) + const followerDidResult = DID.create(request.followerId); + if (followerDidResult.isErr()) { + return err( + new ValidationError( + `Invalid follower ID: ${followerDidResult.error.message}`, + ), + ); + } + const followerDid = followerDidResult.value; + + // 2. Validate targetType + const targetTypeResult = FollowTargetType.create( + request.targetType as any, + ); + if (targetTypeResult.isErr()) { + return err( + new ValidationError( + `Invalid target type: ${targetTypeResult.error.message}`, + ), + ); + } + const targetType = targetTypeResult.value; + + // 3. Find existing follow record + const existingFollowResult = + await this.followsRepository.findByFollowerAndTarget( + request.followerId, + request.targetId, + targetType, + ); + + if (existingFollowResult.isErr()) { + return err(AppError.UnexpectedError.create(existingFollowResult.error)); + } + + // 4. If not found, return success (idempotent) + if (!existingFollowResult.value) { + return ok(undefined); + } + + const follow = existingFollowResult.value; + + // 5. Unpublish from AT Protocol (if has publishedRecordId) + if (follow.publishedRecordId) { + const unpublishResult = + await this.followPublisher.unpublishFollow(follow); + if (unpublishResult.isErr()) { + // Log but don't fail - we still want to delete locally + console.error( + 'Failed to unpublish follow from AT Protocol:', + unpublishResult.error, + ); + } + } + + // 6. Call markForRemoval() (raises UserUnfollowedTargetEvent) + const markResult = follow.markForRemoval(); + if (markResult.isErr()) { + return err(new ValidationError(markResult.error.message)); + } + + // 7. Delete from repository + const deleteResult = await this.followsRepository.delete( + request.followerId, + request.targetId, + targetType, + ); + + if (deleteResult.isErr()) { + return err(AppError.UnexpectedError.create(deleteResult.error)); + } + + // 8. Publish domain events + const publishEventsResult = await this.publishEventsForAggregate(follow); + if (publishEventsResult.isErr()) { + console.error( + 'Failed to publish domain events:', + publishEventsResult.error, + ); + // Don't fail the operation + } + + // 9. Return success + return ok(undefined); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/user/domain/Follow.ts b/src/modules/user/domain/Follow.ts index 8db76bcf..feb65144 100644 --- a/src/modules/user/domain/Follow.ts +++ b/src/modules/user/domain/Follow.ts @@ -4,11 +4,15 @@ import { Guard, IGuardArgument } from 'src/shared/core/Guard'; import { err, ok, Result } from 'src/shared/core/Result'; import { DID } from './value-objects/DID'; import { FollowTargetType } from './value-objects/FollowTargetType'; +import { PublishedRecordId } from '../../cards/domain/value-objects/PublishedRecordId'; +import { UserUnfollowedTargetEvent } from './events/UserUnfollowedTargetEvent'; +import { UserFollowedTargetEvent } from './events/UserFollowedTargetEvent'; export interface FollowProps { followerId: DID; targetId: string; targetType: FollowTargetType; + publishedRecordId?: PublishedRecordId; createdAt: Date; } @@ -33,6 +37,47 @@ export class Follow extends AggregateRoot { return this.props.createdAt; } + get publishedRecordId(): PublishedRecordId | undefined { + return this.props.publishedRecordId; + } + + public markAsPublished(publishedRecordId: PublishedRecordId): void { + this.props.publishedRecordId = publishedRecordId; + } + + public markForRemoval(): Result { + const event = UserUnfollowedTargetEvent.create( + this.followId, + this.followerId, + this.targetId, + this.targetType, + ); + + if (event.isErr()) { + return err(new Error(event.error.message)); + } + + this.addDomainEvent(event.value); + return ok(undefined); + } + + public raiseFollowedEvent(): Result { + const event = UserFollowedTargetEvent.create( + this.followId, + this.followerId, + this.targetId, + this.targetType, + this.createdAt, + ); + + if (event.isErr()) { + return err(new Error(event.error.message)); + } + + this.addDomainEvent(event.value); + return ok(undefined); + } + private constructor(props: FollowProps, id?: UniqueEntityID) { super(props, id); } diff --git a/src/modules/user/domain/events/UserFollowedTargetEvent.ts b/src/modules/user/domain/events/UserFollowedTargetEvent.ts new file mode 100644 index 00000000..db19aef3 --- /dev/null +++ b/src/modules/user/domain/events/UserFollowedTargetEvent.ts @@ -0,0 +1,64 @@ +import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { DID } from '../value-objects/DID'; +import { FollowTargetType } from '../value-objects/FollowTargetType'; +import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; +import { Result, ok } from '../../../../shared/core/Result'; + +export class UserFollowedTargetEvent implements IDomainEvent { + public readonly eventName = EventNames.USER_FOLLOWED_TARGET; + public readonly dateTimeOccurred: Date; + + private constructor( + public readonly followId: UniqueEntityID, + public readonly followerId: DID, + public readonly targetId: string, + public readonly targetType: FollowTargetType, + public readonly createdAt: Date, + dateTimeOccurred?: Date, + ) { + this.dateTimeOccurred = dateTimeOccurred || new Date(); + } + + public static create( + followId: UniqueEntityID, + followerId: DID, + targetId: string, + targetType: FollowTargetType, + createdAt: Date, + ): Result { + return ok( + new UserFollowedTargetEvent( + followId, + followerId, + targetId, + targetType, + createdAt, + ), + ); + } + + public static reconstruct( + followId: UniqueEntityID, + followerId: DID, + targetId: string, + targetType: FollowTargetType, + createdAt: Date, + dateTimeOccurred: Date, + ): Result { + return ok( + new UserFollowedTargetEvent( + followId, + followerId, + targetId, + targetType, + createdAt, + dateTimeOccurred, + ), + ); + } + + getAggregateId(): UniqueEntityID { + return this.followId; + } +} diff --git a/src/modules/user/domain/events/UserUnfollowedTargetEvent.ts b/src/modules/user/domain/events/UserUnfollowedTargetEvent.ts new file mode 100644 index 00000000..5d5701c4 --- /dev/null +++ b/src/modules/user/domain/events/UserUnfollowedTargetEvent.ts @@ -0,0 +1,54 @@ +import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { DID } from '../value-objects/DID'; +import { FollowTargetType } from '../value-objects/FollowTargetType'; +import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; +import { Result, ok } from '../../../../shared/core/Result'; + +export class UserUnfollowedTargetEvent implements IDomainEvent { + public readonly eventName = EventNames.USER_UNFOLLOWED_TARGET; + public readonly dateTimeOccurred: Date; + + private constructor( + public readonly followId: UniqueEntityID, + public readonly followerId: DID, + public readonly targetId: string, + public readonly targetType: FollowTargetType, + dateTimeOccurred?: Date, + ) { + this.dateTimeOccurred = dateTimeOccurred || new Date(); + } + + public static create( + followId: UniqueEntityID, + followerId: DID, + targetId: string, + targetType: FollowTargetType, + ): Result { + return ok( + new UserUnfollowedTargetEvent(followId, followerId, targetId, targetType), + ); + } + + public static reconstruct( + followId: UniqueEntityID, + followerId: DID, + targetId: string, + targetType: FollowTargetType, + dateTimeOccurred: Date, + ): Result { + return ok( + new UserUnfollowedTargetEvent( + followId, + followerId, + targetId, + targetType, + dateTimeOccurred, + ), + ); + } + + getAggregateId(): UniqueEntityID { + return this.followId; + } +} diff --git a/src/modules/user/domain/repositories/IFollowsRepository.ts b/src/modules/user/domain/repositories/IFollowsRepository.ts index f27af15d..37333806 100644 --- a/src/modules/user/domain/repositories/IFollowsRepository.ts +++ b/src/modules/user/domain/repositories/IFollowsRepository.ts @@ -38,4 +38,44 @@ export interface IFollowsRepository { * (deduplication happens at use case level) */ getFollowersOfCollections(collectionIds: string[]): Promise>; + + /** + * Save a follow relationship. + * + * @param follow - The follow entity to persist + * @returns Success or error + * + * Idempotency: Uses INSERT ON CONFLICT DO NOTHING on composite key + */ + save(follow: Follow): Promise>; + + /** + * Delete a follow relationship. + * + * @param followerId - DID of the follower + * @param targetId - ID of the target (user DID or collection UUID) + * @param targetType - Type of target + * @returns Success or error + * + * Idempotency: Returns success even if follow doesn't exist + */ + delete( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise>; + + /** + * Find a specific follow relationship. + * + * @param followerId - DID of the follower + * @param targetId - ID of the target + * @param targetType - Type of target + * @returns Follow entity or null if not found + */ + findByFollowerAndTarget( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise>; } diff --git a/src/modules/user/infrastructure/http/controllers/FollowTargetController.ts b/src/modules/user/infrastructure/http/controllers/FollowTargetController.ts new file mode 100644 index 00000000..ff4818de --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/FollowTargetController.ts @@ -0,0 +1,43 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { FollowTargetUseCase } from '../../../application/useCases/commands/FollowTargetUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export class FollowTargetController extends Controller { + constructor(private followTargetUseCase: FollowTargetUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { targetId, targetType } = req.body; + const followerId = req.did; + + if (!followerId) { + return this.unauthorized(res); + } + + if (!targetId || !targetType) { + return this.badRequest(res, 'Target ID and type are required'); + } + + const result = await this.followTargetUseCase.execute({ + followerId, + targetId, + targetType, + }); + + if (result.isErr()) { + if (result.error instanceof AuthenticationError) { + return this.unauthorized(res, result.error.message); + } + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.handleError(res, error); + } + } +} diff --git a/src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts b/src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts new file mode 100644 index 00000000..cef68e1d --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts @@ -0,0 +1,43 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { UnfollowTargetUseCase } from '../../../application/useCases/commands/UnfollowTargetUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; +import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; + +export class UnfollowTargetController extends Controller { + constructor(private unfollowTargetUseCase: UnfollowTargetUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + const { targetId, targetType } = req.params; + const followerId = req.did; + + if (!followerId) { + return this.unauthorized(res); + } + + if (!targetId || !targetType) { + return this.badRequest(res, 'Target ID and type are required'); + } + + const result = await this.unfollowTargetUseCase.execute({ + followerId, + targetId, + targetType: targetType as 'USER' | 'COLLECTION', + }); + + if (result.isErr()) { + if (result.error instanceof AuthenticationError) { + return this.unauthorized(res, result.error.message); + } + return this.fail(res, result.error); + } + + return this.ok(res); + } catch (error: any) { + return this.handleError(res, error); + } + } +} diff --git a/src/modules/user/infrastructure/http/routes/userRoutes.ts b/src/modules/user/infrastructure/http/routes/userRoutes.ts index 9dee2467..a29dd841 100644 --- a/src/modules/user/infrastructure/http/routes/userRoutes.ts +++ b/src/modules/user/infrastructure/http/routes/userRoutes.ts @@ -8,6 +8,8 @@ import { GetMyProfileController } from 'src/modules/cards/infrastructure/http/co import { GetUserProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetUserProfileController'; import { LogoutController } from '../controllers/LogoutController'; import { GenerateExtensionTokensController } from '../controllers/GenerateExtensionTokensController'; +import { FollowTargetController } from '../controllers/FollowTargetController'; +import { UnfollowTargetController } from '../controllers/UnfollowTargetController'; export const createUserRoutes = ( router: Router, @@ -20,6 +22,8 @@ export const createUserRoutes = ( getUserProfileController: GetUserProfileController, refreshAccessTokenController: RefreshAccessTokenController, generateExtensionTokensController: GenerateExtensionTokensController, + followTargetController: FollowTargetController, + unfollowTargetController: UnfollowTargetController, ) => { // Public routes router.get('/login', (req, res) => @@ -52,5 +56,16 @@ export const createUserRoutes = ( (req, res) => generateExtensionTokensController.execute(req, res), ); + // Follow/Unfollow routes + router.post('/follows', authMiddleware.ensureAuthenticated(), (req, res) => + followTargetController.execute(req, res), + ); + + router.delete( + '/follows/:targetId/:targetType', + authMiddleware.ensureAuthenticated(), + (req, res) => unfollowTargetController.execute(req, res), + ); + return router; }; diff --git a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts index 52a3df08..08ebe6f5 100644 --- a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts +++ b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts @@ -1,5 +1,6 @@ import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { eq, and, sql } from 'drizzle-orm'; +import { randomUUID } from 'crypto'; import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; import { Follow } from '../../domain/Follow'; import { DID } from '../../domain/value-objects/DID'; @@ -8,6 +9,8 @@ import { FollowTargetTypeEnum, } from '../../domain/value-objects/FollowTargetType'; import { follows } from './schema/follows.sql'; +import { publishedRecords } from '../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; +import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; import { err, ok, Result } from 'src/shared/core/Result'; @@ -142,4 +145,150 @@ export class DrizzleFollowsRepository implements IFollowsRepository { return err(error); } } + + async save(follow: Follow): Promise> { + try { + // Handle publishedRecordId persistence (similar to Card repository) + let publishedRecordIdUuid: string | undefined; + if (follow.publishedRecordId) { + const publishedRecordId = follow.publishedRecordId.getValue(); + + // Insert or get existing published record + const existingRecord = await this.db + .select() + .from(publishedRecords) + .where( + and( + eq(publishedRecords.uri, publishedRecordId.uri), + eq(publishedRecords.cid, publishedRecordId.cid), + ), + ) + .limit(1); + + if (existingRecord.length > 0) { + publishedRecordIdUuid = existingRecord[0]!.id; + } else { + const insertResult = await this.db + .insert(publishedRecords) + .values({ + id: randomUUID(), + uri: publishedRecordId.uri, + cid: publishedRecordId.cid, + }) + .returning({ id: publishedRecords.id }); + publishedRecordIdUuid = insertResult[0]!.id; + } + } + + await this.db + .insert(follows) + .values({ + followerId: follow.followerId.value, + targetId: follow.targetId, + targetType: follow.targetType.value, + publishedRecordId: publishedRecordIdUuid, + createdAt: follow.createdAt, + }) + .onConflictDoUpdate({ + target: [follows.followerId, follows.targetId, follows.targetType], + set: { + publishedRecordId: publishedRecordIdUuid, + }, + }); + + return ok(undefined); + } catch (error) { + return err(error as Error); + } + } + + async delete( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + await this.db + .delete(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.targetId, targetId), + eq(follows.targetType, targetType.value), + ), + ); + + return ok(undefined); + } catch (error) { + return err(error as Error); + } + } + + async findByFollowerAndTarget( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const results = await this.db + .select({ + follow: follows, + publishedRecord: publishedRecords, + }) + .from(follows) + .leftJoin( + publishedRecords, + eq(follows.publishedRecordId, publishedRecords.id), + ) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.targetId, targetId), + eq(follows.targetType, targetType.value), + ), + ) + .limit(1); + + if (results.length === 0) { + return ok(null); + } + + const row = results[0]!; + const followerDid = DID.create(row.follow.followerId); + if (followerDid.isErr()) { + return err(followerDid.error); + } + + const targetTypeVO = FollowTargetType.create( + row.follow.targetType as any, + ); + if (targetTypeVO.isErr()) { + return err(targetTypeVO.error); + } + + // Reconstruct publishedRecordId if present + let publishedRecordId: PublishedRecordId | undefined; + if (row.publishedRecord) { + publishedRecordId = PublishedRecordId.create({ + uri: row.publishedRecord.uri, + cid: row.publishedRecord.cid, + }); + } + + return Follow.create( + { + followerId: followerDid.value, + targetId: row.follow.targetId, + targetType: targetTypeVO.value, + publishedRecordId, + createdAt: row.follow.createdAt, + }, + new UniqueEntityID( + `${row.follow.followerId}:${row.follow.targetId}:${row.follow.targetType}`, + ), + ); + } catch (error) { + return err(error as Error); + } + } } diff --git a/src/modules/user/infrastructure/repositories/schema/follows.sql.ts b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts index 8f387253..540d7b2c 100644 --- a/src/modules/user/infrastructure/repositories/schema/follows.sql.ts +++ b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts @@ -4,7 +4,9 @@ import { timestamp, primaryKey, index, + uuid, } from 'drizzle-orm/pg-core'; +import { publishedRecords } from '../../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; export const follows = pgTable( 'follows', @@ -12,6 +14,9 @@ export const follows = pgTable( followerId: text('follower_id').notNull(), targetId: text('target_id').notNull(), targetType: text('target_type').notNull(), + publishedRecordId: uuid('published_record_id').references( + () => publishedRecords.id, + ), createdAt: timestamp('created_at').notNull().defaultNow(), }, (table) => ({ diff --git a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts index aeff16a7..349de3e4 100644 --- a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts +++ b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts @@ -77,6 +77,34 @@ export class InMemoryFollowsRepository implements IFollowsRepository { } } + async delete( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const key = `${followerId}:${targetId}:${targetType.value}`; + this.follows.delete(key); + return ok(undefined); + } catch (error: any) { + return err(error); + } + } + + async findByFollowerAndTarget( + followerId: string, + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const key = `${followerId}:${targetId}:${targetType.value}`; + const follow = this.follows.get(key); + return ok(follow || null); + } catch (error: any) { + return err(error); + } + } + // Helper method for testing clear(): void { this.follows.clear(); diff --git a/src/shared/constants/atproto.ts b/src/shared/constants/atproto.ts index 107c528f..cc02d48e 100644 --- a/src/shared/constants/atproto.ts +++ b/src/shared/constants/atproto.ts @@ -15,5 +15,6 @@ export const ATPROTO_NSID = { COLLECTION: 'network.cosmik.collection', COLLECTION_LINK: 'network.cosmik.collectionLink', COLLECTION_LINK_REMOVAL: 'network.cosmik.collectionLinkRemoval', + FOLLOW: 'network.cosmik.follow', }, } as const; diff --git a/src/shared/infrastructure/config/EnvironmentConfigService.ts b/src/shared/infrastructure/config/EnvironmentConfigService.ts index 874fabeb..343b8d78 100644 --- a/src/shared/infrastructure/config/EnvironmentConfigService.ts +++ b/src/shared/infrastructure/config/EnvironmentConfigService.ts @@ -36,6 +36,7 @@ export interface EnvironmentConfig { marginCollection: string; marginCollectionItem: string; collectionLinkRemoval: string; + follow: string; }; serviceAccount: { identifier: string; @@ -128,6 +129,10 @@ export class EnvironmentConfigService { environment === Environment.PROD ? ATPROTO_NSID.COSMIK.COLLECTION_LINK_REMOVAL : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.collectionLinkRemoval`, + follow: + environment === Environment.PROD + ? ATPROTO_NSID.COSMIK.FOLLOW + : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.follow`, // Margin collections - no environment suffix marginBookmark: ATPROTO_NSID.MARGIN.BOOKMARK, marginCollection: ATPROTO_NSID.MARGIN.COLLECTION, diff --git a/src/shared/infrastructure/database/migrations/0017_heavy_inertia.sql b/src/shared/infrastructure/database/migrations/0017_heavy_inertia.sql new file mode 100644 index 00000000..4a7c502b --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0017_heavy_inertia.sql @@ -0,0 +1,2 @@ +ALTER TABLE "follows" ADD COLUMN "published_record_id" uuid;--> statement-breakpoint +ALTER TABLE "follows" ADD CONSTRAINT "follows_published_record_id_published_records_id_fk" FOREIGN KEY ("published_record_id") REFERENCES "public"."published_records"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0017_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..afe0fd71 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0017_snapshot.json @@ -0,0 +1,1574 @@ +{ + "id": "3c3e6afe-11db-4e0c-918b-e358b4a761e4", + "prevId": "1186ff74-4783-4cc7-94c4-ae3e8f93ac59", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cards_author_url_idx": { + "name": "cards_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_author_id_idx": { + "name": "cards_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_type_updated_at": { + "name": "idx_cards_type_updated_at", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type": { + "name": "idx_cards_url_type", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type_filter": { + "name": "idx_cards_url_type_filter", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_parent_type": { + "name": "idx_cards_parent_type", + "columns": [ + { + "expression": "parent_card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "type = 'NOTE'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["parent_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_via_card_id_cards_id_fk": { + "name": "cards_via_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_cards_card_id_idx": { + "name": "collection_cards_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_cards_collection_id_idx": { + "name": "collection_cards_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_collection_added": { + "name": "idx_collection_cards_collection_added", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_card_collection": { + "name": "idx_collection_cards_card_collection", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_added_by_added_at": { + "name": "idx_collection_cards_added_by_added_at", + "columns": [ + { + "expression": "added_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_via_card_id_cards_id_fk": { + "name": "collection_cards_via_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collections_author_id_idx": { + "name": "collections_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_author_updated_at_idx": { + "name": "collections_author_updated_at_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_library_memberships_user_type_covering": { + "name": "idx_library_memberships_user_type_covering", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": ["card_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_activities_type_idx": { + "name": "feed_activities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_idx": { + "name": "feed_activities_url_type_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_created_at_idx": { + "name": "feed_activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_created_at_idx": { + "name": "feed_activities_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_created_at_idx": { + "name": "feed_activities_url_type_created_at_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_url_type_created_at_idx": { + "name": "feed_activities_type_url_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_dedup_idx": { + "name": "feed_activities_dedup_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_card_id_idx": { + "name": "feed_activities_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_source_idx": { + "name": "feed_activities_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.following_feed_items": { + "name": "following_feed_items", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_following_feed_user_time": { + "name": "idx_following_feed_user_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "following_feed_items_activity_id_feed_activities_id_fk": { + "name": "following_feed_items_activity_id_feed_activities_id_fk", + "tableFrom": "following_feed_items", + "tableTo": "feed_activities", + "columnsFrom": ["activity_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "following_feed_items_user_id_activity_id_pk": { + "name": "following_feed_items_user_id_activity_id_pk", + "columns": ["user_id", "activity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_idx": { + "name": "notifications_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_created_at_idx": { + "name": "notifications_recipient_created_at_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_statuses": { + "name": "sync_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_state": { + "name": "sync_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_attempt_at": { + "name": "last_sync_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error_message": { + "name": "sync_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "records_processed": { + "name": "records_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sync_statuses_curator_id_unique": { + "name": "sync_statuses_curator_id_unique", + "nullsNotDistinct": false, + "columns": ["curator_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_did"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_follows_follower": { + "name": "idx_follows_follower", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_follows_target": { + "name": "idx_follows_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_published_record_id_published_records_id_fk": { + "name": "follows_published_record_id_published_records_id_fk", + "tableFrom": "follows", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_target_id_target_type_pk": { + "name": "follows_follower_id_target_id_target_type_pk", + "columns": ["follower_id", "target_id", "target_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index f28d20e4..fafb78d1 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1770427721556, "tag": "0016_ancient_cerise", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1770860659903, + "tag": "0017_heavy_inertia", + "breakpoints": true } ] } diff --git a/src/shared/infrastructure/events/BullMQEventPublisher.ts b/src/shared/infrastructure/events/BullMQEventPublisher.ts index bfecd63d..eaaaf4cf 100644 --- a/src/shared/infrastructure/events/BullMQEventPublisher.ts +++ b/src/shared/infrastructure/events/BullMQEventPublisher.ts @@ -65,6 +65,10 @@ export class BullMQEventPublisher implements IEventPublisher { return [QueueNames.NOTIFICATIONS]; case EventNames.CARD_REMOVED_FROM_COLLECTION: return [QueueNames.NOTIFICATIONS]; + case EventNames.USER_FOLLOWED_TARGET: + return [QueueNames.NOTIFICATIONS]; + case EventNames.USER_UNFOLLOWED_TARGET: + return [QueueNames.NOTIFICATIONS]; default: return [QueueNames.FEEDS]; } diff --git a/src/shared/infrastructure/events/EventConfig.ts b/src/shared/infrastructure/events/EventConfig.ts index 443e371a..24370fcb 100644 --- a/src/shared/infrastructure/events/EventConfig.ts +++ b/src/shared/infrastructure/events/EventConfig.ts @@ -4,6 +4,8 @@ export const EventNames = { COLLECTION_CREATED: 'CollectionCreatedEvent', CARD_REMOVED_FROM_LIBRARY: 'CardRemovedFromLibraryEvent', CARD_REMOVED_FROM_COLLECTION: 'CardRemovedFromCollectionEvent', + USER_FOLLOWED_TARGET: 'USER_FOLLOWED_TARGET', + USER_UNFOLLOWED_TARGET: 'USER_UNFOLLOWED_TARGET', } as const; export type EventName = (typeof EventNames)[keyof typeof EventNames]; diff --git a/src/shared/infrastructure/events/EventMapper.ts b/src/shared/infrastructure/events/EventMapper.ts index 26610aab..14e70a77 100644 --- a/src/shared/infrastructure/events/EventMapper.ts +++ b/src/shared/infrastructure/events/EventMapper.ts @@ -4,9 +4,14 @@ import { CardAddedToCollectionEvent } from '../../../modules/cards/domain/events import { CardRemovedFromLibraryEvent } from '../../../modules/cards/domain/events/CardRemovedFromLibraryEvent'; import { CardRemovedFromCollectionEvent } from '../../../modules/cards/domain/events/CardRemovedFromCollectionEvent'; import { CollectionCreatedEvent } from '../../../modules/cards/domain/events/CollectionCreatedEvent'; +import { UserFollowedTargetEvent } from '../../../modules/user/domain/events/UserFollowedTargetEvent'; +import { UserUnfollowedTargetEvent } from '../../../modules/user/domain/events/UserUnfollowedTargetEvent'; import { CardId } from '../../../modules/cards/domain/value-objects/CardId'; import { CollectionId } from '../../../modules/cards/domain/value-objects/CollectionId'; import { CuratorId } from '../../../modules/cards/domain/value-objects/CuratorId'; +import { DID } from '../../../modules/user/domain/value-objects/DID'; +import { FollowTargetType } from '../../../modules/user/domain/value-objects/FollowTargetType'; +import { UniqueEntityID } from '../../domain/UniqueEntityID'; import { EventNames } from './EventConfig'; export interface SerializedEvent { @@ -51,12 +56,31 @@ export interface SerializedCollectionCreatedEvent extends SerializedEvent { collectionName: string; } +export interface SerializedUserFollowedTargetEvent extends SerializedEvent { + eventType: typeof EventNames.USER_FOLLOWED_TARGET; + followId: string; + followerId: string; + targetId: string; + targetType: 'USER' | 'COLLECTION'; + createdAt: string; +} + +export interface SerializedUserUnfollowedTargetEvent extends SerializedEvent { + eventType: typeof EventNames.USER_UNFOLLOWED_TARGET; + followId: string; + followerId: string; + targetId: string; + targetType: 'USER' | 'COLLECTION'; +} + export type SerializedEventUnion = | SerializedCardAddedToLibraryEvent | SerializedCardAddedToCollectionEvent | SerializedCardRemovedFromLibraryEvent | SerializedCardRemovedFromCollectionEvent - | SerializedCollectionCreatedEvent; + | SerializedCollectionCreatedEvent + | SerializedUserFollowedTargetEvent + | SerializedUserUnfollowedTargetEvent; export class EventMapper { static toSerialized(event: IDomainEvent): SerializedEventUnion { @@ -115,6 +139,31 @@ export class EventMapper { }; } + if (event instanceof UserFollowedTargetEvent) { + return { + eventType: EventNames.USER_FOLLOWED_TARGET, + aggregateId: event.getAggregateId().toString(), + dateTimeOccurred: event.dateTimeOccurred.toISOString(), + followId: event.followId.toString(), + followerId: event.followerId.value, + targetId: event.targetId, + targetType: event.targetType.value, + createdAt: event.createdAt.toISOString(), + }; + } + + if (event instanceof UserUnfollowedTargetEvent) { + return { + eventType: EventNames.USER_UNFOLLOWED_TARGET, + aggregateId: event.getAggregateId().toString(), + dateTimeOccurred: event.dateTimeOccurred.toISOString(), + followId: event.followId.toString(), + followerId: event.followerId.value, + targetId: event.targetId, + targetType: event.targetType.value, + }; + } + throw new Error( `Unknown event type for serialization: ${event.constructor.name}`, ); @@ -192,6 +241,38 @@ export class EventMapper { dateTimeOccurred, ).unwrap(); } + case EventNames.USER_FOLLOWED_TARGET: { + const followId = new UniqueEntityID(eventData.followId); + const followerId = DID.create(eventData.followerId).unwrap(); + const targetType = FollowTargetType.create( + eventData.targetType as any, + ).unwrap(); + const createdAt = new Date(eventData.createdAt); + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); + return UserFollowedTargetEvent.reconstruct( + followId, + followerId, + eventData.targetId, + targetType, + createdAt, + dateTimeOccurred, + ).unwrap(); + } + case EventNames.USER_UNFOLLOWED_TARGET: { + const followId = new UniqueEntityID(eventData.followId); + const followerId = DID.create(eventData.followerId).unwrap(); + const targetType = FollowTargetType.create( + eventData.targetType as any, + ).unwrap(); + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); + return UserUnfollowedTargetEvent.reconstruct( + followId, + followerId, + eventData.targetId, + targetType, + dateTimeOccurred, + ).unwrap(); + } default: throw new Error(`Unknown event type for deserialization: ${eventData}`); } diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 183d7c5a..26585cb6 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -83,6 +83,8 @@ export const createExpressApp = ( controllers.getUserProfileController, controllers.refreshAccessTokenController, controllers.generateExtensionTokensController, + controllers.followTargetController, + controllers.unfollowTargetController, ); createAtprotoRoutes(atprotoRouter, services.nodeOauthClient); diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index d1810be3..ea4160d5 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -43,6 +43,8 @@ import { GetMyNotificationsController } from '../../../../modules/notifications/ import { GetUnreadNotificationCountController } from '../../../../modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController'; import { MarkNotificationsAsReadController } from '../../../../modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController'; import { MarkAllNotificationsAsReadController } from '../../../../modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController'; +import { FollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/FollowTargetController'; +import { UnfollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/UnfollowTargetController'; import { CookieService } from '../services/CookieService'; export interface Controllers { @@ -55,6 +57,8 @@ export interface Controllers { getUserProfileController: GetUserProfileController; refreshAccessTokenController: RefreshAccessTokenController; generateExtensionTokensController: GenerateExtensionTokensController; + followTargetController: FollowTargetController; + unfollowTargetController: UnfollowTargetController; // Card controllers addUrlToLibraryController: AddUrlToLibraryController; addCardToLibraryController: AddCardToLibraryController; @@ -129,6 +133,12 @@ export class ControllerFactory { generateExtensionTokensController: new GenerateExtensionTokensController( useCases.generateExtensionTokensUseCase, ), + followTargetController: new FollowTargetController( + useCases.followTargetUseCase, + ), + unfollowTargetController: new UnfollowTargetController( + useCases.unfollowTargetUseCase, + ), // Card controllers addUrlToLibraryController: new AddUrlToLibraryController( diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index 97887221..99ef5c5a 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -15,8 +15,10 @@ import { BlueskyProfileService } from '../../../../modules/atproto/infrastructur import { CachedBlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/CachedBlueskyProfileService'; import { ATProtoCollectionPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher'; import { ATProtoCardPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCardPublisher'; +import { ATProtoFollowPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoFollowPublisher'; import { FakeCollectionPublisher } from '../../../../modules/cards/tests/utils/FakeCollectionPublisher'; import { FakeCardPublisher } from '../../../../modules/cards/tests/utils/FakeCardPublisher'; +import { FakeFollowPublisher } from '../../../../modules/atproto/infrastructure/publishers/FakeFollowPublisher'; import { CardLibraryService } from '../../../../modules/cards/domain/services/CardLibraryService'; import { CardCollectionService } from '../../../../modules/cards/domain/services/CardCollectionService'; import { AuthMiddleware } from '../middleware/AuthMiddleware'; @@ -26,6 +28,7 @@ import { AppPasswordSessionService } from 'src/modules/atproto/infrastructure/se import { AtpAppPasswordProcessor } from 'src/modules/atproto/infrastructure/services/AtpAppPasswordProcessor'; import { ICollectionPublisher } from 'src/modules/cards/application/ports/ICollectionPublisher'; import { ICardPublisher } from 'src/modules/cards/application/ports/ICardPublisher'; +import { IFollowPublisher } from 'src/modules/user/application/ports/IFollowPublisher'; import { IMetadataService } from 'src/modules/cards/domain/services/IMetadataService'; import { BullMQEventSubscriber } from '../../events/BullMQEventSubscriber'; import { BullMQEventPublisher } from '../../events/BullMQEventPublisher'; @@ -98,6 +101,7 @@ export interface WebAppServices extends SharedServices { appPasswordProcessor: IAppPasswordProcessor; collectionPublisher: ICollectionPublisher; cardPublisher: ICardPublisher; + followPublisher: IFollowPublisher; authMiddleware: AuthMiddleware; } @@ -166,6 +170,14 @@ export class ServiceFactory { collections.card, ); + const followPublisher = useFakePublishers + ? new FakeFollowPublisher() + : new ATProtoFollowPublisher( + sharedServices.atProtoAgentService, + collections.follow, + repositories.collectionRepository, + ); + const authMiddleware = new AuthMiddleware( sharedServices.tokenService, sharedServices.cookieService, @@ -177,6 +189,7 @@ export class ServiceFactory { appPasswordProcessor, collectionPublisher, cardPublisher, + followPublisher, authMiddleware, }; } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index b8c1e55e..4bbf1e70 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -52,6 +52,8 @@ import { MarkNotificationsAsReadUseCase } from '../../../../modules/notification import { MarkAllNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase'; import { CreateNotificationUseCase } from '../../../../modules/notifications/application/useCases/commands/CreateNotificationUseCase'; import { SyncAccountDataUseCase } from '../../../../modules/sync/application/useCases/SyncAccountDataUseCase'; +import { FollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/FollowTargetUseCase'; +import { UnfollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/UnfollowTargetUseCase'; export interface WorkerUseCases { addActivityToFeedUseCase: AddActivityToFeedUseCase; @@ -83,6 +85,8 @@ export interface UseCases { getProfileUseCase: GetProfileUseCase; refreshAccessTokenUseCase: RefreshAccessTokenUseCase; generateExtensionTokensUseCase: GenerateExtensionTokensUseCase; + followTargetUseCase: FollowTargetUseCase; + unfollowTargetUseCase: UnfollowTargetUseCase; // Card use cases addUrlToLibraryUseCase: AddUrlToLibraryUseCase; addCardToLibraryUseCase: AddCardToLibraryUseCase; @@ -167,6 +171,18 @@ export class UseCaseFactory { generateExtensionTokensUseCase: new GenerateExtensionTokensUseCase( services.tokenService, ), + followTargetUseCase: new FollowTargetUseCase( + repositories.followsRepository, + repositories.userRepository, + repositories.collectionRepository, + services.followPublisher, + services.eventPublisher, + ), + unfollowTargetUseCase: new UnfollowTargetUseCase( + repositories.followsRepository, + services.followPublisher, + services.eventPublisher, + ), // Card use cases addUrlToLibraryUseCase: new AddUrlToLibraryUseCase( diff --git a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts index 22ddca68..bb171e75 100644 --- a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts +++ b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts @@ -9,6 +9,7 @@ import { CardAddedToCollectionEventHandler } from '../../../modules/notification import { CardRemovedFromLibraryEventHandler } from '../../../modules/notifications/application/eventHandlers/CardRemovedFromLibraryEventHandler'; import { CollectionContributionEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionEventHandler'; import { CollectionContributionCleanupEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionCleanupEventHandler'; +import { UserFollowedTargetEventHandler } from '../../../modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler'; import { CardNotificationSaga } from '../../../modules/notifications/application/sagas/CardNotificationSaga'; import { QueueNames } from '../events/QueueConfig'; import { EventNames } from '../events/EventConfig'; @@ -70,6 +71,13 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { repositories.collectionRepository, ); + // Follow notification handler + const userFollowedTargetHandler = new UserFollowedTargetEventHandler( + services.notificationService, + repositories.userRepository, + repositories.collectionRepository, + ); + await subscriber.subscribe( EventNames.CARD_ADDED_TO_LIBRARY, cardAddedToLibraryHandler, @@ -96,5 +104,10 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { EventNames.CARD_REMOVED_FROM_COLLECTION, collectionContributionCleanupHandler, ); + + await subscriber.subscribe( + EventNames.USER_FOLLOWED_TARGET, + userFollowedTargetHandler, + ); } } diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index f39c8792..9aed379d 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -241,3 +241,14 @@ export interface GetMyNotificationsParams extends PaginatedSortedParams { export interface MarkNotificationsAsReadRequest { notificationIds: string[]; } + +// Follow request types +export interface FollowTargetRequest { + targetId: string; // DID or Collection UUID + targetType: 'USER' | 'COLLECTION'; +} + +export interface UnfollowTargetRequest { + targetId: string; + targetType: 'USER' | 'COLLECTION'; +} diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index d8e19409..88b2fa93 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -335,6 +335,8 @@ export enum NotificationType { USER_ADDED_YOUR_BSKY_POST = 'USER_ADDED_YOUR_BSKY_POST', USER_ADDED_YOUR_COLLECTION = 'USER_ADDED_YOUR_COLLECTION', USER_ADDED_TO_YOUR_COLLECTION = 'USER_ADDED_TO_YOUR_COLLECTION', + USER_FOLLOWED_YOU = 'USER_FOLLOWED_YOU', + USER_FOLLOWED_YOUR_COLLECTION = 'USER_FOLLOWED_YOUR_COLLECTION', } export interface NotificationItem { @@ -364,3 +366,8 @@ export interface MarkNotificationsAsReadResponse { export interface MarkAllNotificationsAsReadResponse { markedCount: number; } + +// Follow response types +export interface FollowTargetResponse { + followId: string; +} diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 89e90e9d..8941a07b 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -82,6 +82,8 @@ import type { GetGemActivityFeedParams, SearchCollectionsParams, GetOpenCollectionsWithContributorParams, + FollowTargetRequest, + FollowTargetResponse, } from '@semble/types'; // Main API Client class using composition @@ -326,6 +328,19 @@ export class ApiClient { return this.userClient.logout(); } + async followTarget( + request: FollowTargetRequest, + ): Promise { + return this.userClient.followTarget(request); + } + + async unfollowTarget( + targetId: string, + targetType: 'USER' | 'COLLECTION', + ): Promise { + return this.userClient.unfollowTarget(targetId, targetType); + } + // Feed operations - delegate to FeedClient async getGlobalFeed( params?: GetGlobalFeedParams, diff --git a/src/webapp/api-client/clients/UserClient.ts b/src/webapp/api-client/clients/UserClient.ts index 3d699485..d7736e53 100644 --- a/src/webapp/api-client/clients/UserClient.ts +++ b/src/webapp/api-client/clients/UserClient.ts @@ -10,6 +10,8 @@ import { CompleteOAuthSignInResponse, RefreshAccessTokenResponse, GenerateExtensionTokensResponse, + FollowTargetRequest, + FollowTargetResponse, } from '@semble/types'; export class UserClient extends BaseClient { @@ -77,4 +79,24 @@ export class UserClient extends BaseClient { '/api/users/logout', ); } + + async followTarget( + request: FollowTargetRequest, + ): Promise { + return this.request( + 'POST', + '/api/users/follows', + request, + ); + } + + async unfollowTarget( + targetId: string, + targetType: 'USER' | 'COLLECTION', + ): Promise { + return this.request( + 'DELETE', + `/api/users/follows/${targetId}/${targetType}`, + ); + } }