This repository has no description
1# Implementation Plan: FollowTargetUseCase & UnfollowTargetUseCase
2
3## Overview
4
5Implement follow/unfollow functionality with AT Protocol publishing and notification support, following DDD patterns with proper validation and event-driven architecture.
6
7## User Requirements (Confirmed)
8
9- ✅ Publish to AT Protocol (network.cosmik.follow)
10- ✅ Send notifications when users are followed
11- ✅ Validate target existence before allowing follow
12- ✅ Prevent self-follows (business rule)
13- ✅ Store publishedRecordId in Follow aggregate (similar to Card)
14
15## Implementation Steps
16
17### Phase 0: AT Protocol Lexicon Definition
18
19#### 0.1 Create network.cosmik.follow Lexicon
20
21**File:** `src/modules/atproto/infrastructure/lexicons/follow.json` (NEW)
22
23**Implementation:**
24
25```json
26{
27 "lexicon": 1,
28 "id": "network.cosmik.follow",
29 "description": "A record representing a follow relationship.",
30 "defs": {
31 "main": {
32 "type": "record",
33 "description": "A record representing a follow of a user or collection.",
34 "key": "tid",
35 "record": {
36 "type": "object",
37 "required": ["subject", "createdAt"],
38 "properties": {
39 "subject": {
40 "type": "string",
41 "description": "DID of the user being followed, or AT URI of the collection being followed"
42 },
43 "createdAt": {
44 "type": "string",
45 "format": "datetime",
46 "description": "Timestamp when this follow was created."
47 }
48 }
49 }
50 }
51 }
52}
53```
54
55**Notes:**
56
57- Similar to app.bsky.graph.follow but under network.cosmik namespace
58- `subject` can be either a DID (for user follows) or an AT URI (for collection follows)
59- Collections will be referenced via their published AT URI (at://did/network.cosmik.collection/rkey)
60
61---
62
63### Phase 1: Domain Layer - Events & Aggregate Updates
64
65#### 1.1 Add Event Names to EventConfig
66
67**File:** `src/shared/infrastructure/events/EventConfig.ts`
68
69**Changes:**
70
71```typescript
72export const EventNames = {
73 // ... existing events ...
74 USER_FOLLOWED_TARGET: 'USER_FOLLOWED_TARGET',
75 USER_UNFOLLOWED_TARGET: 'USER_UNFOLLOWED_TARGET',
76} as const;
77```
78
79---
80
81#### 1.2 Create UserFollowedTargetEvent
82
83**File:** `src/modules/user/domain/events/UserFollowedTargetEvent.ts` (NEW)
84
85**Implementation:**
86
87```typescript
88import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent';
89import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
90import { DID } from '../value-objects/DID';
91import { FollowTargetType } from '../value-objects/FollowTargetType';
92import { EventNames } from '../../../../shared/infrastructure/events/EventConfig';
93import { Result, ok } from '../../../../shared/core/Result';
94
95export class UserFollowedTargetEvent implements IDomainEvent {
96 public readonly eventName = EventNames.USER_FOLLOWED_TARGET;
97 public readonly dateTimeOccurred: Date;
98
99 private constructor(
100 public readonly followId: UniqueEntityID,
101 public readonly followerId: DID,
102 public readonly targetId: string,
103 public readonly targetType: FollowTargetType,
104 public readonly createdAt: Date,
105 dateTimeOccurred?: Date,
106 ) {
107 this.dateTimeOccurred = dateTimeOccurred || new Date();
108 }
109
110 public static create(
111 followId: UniqueEntityID,
112 followerId: DID,
113 targetId: string,
114 targetType: FollowTargetType,
115 createdAt: Date,
116 ): Result<UserFollowedTargetEvent> {
117 return ok(
118 new UserFollowedTargetEvent(
119 followId,
120 followerId,
121 targetId,
122 targetType,
123 createdAt,
124 ),
125 );
126 }
127
128 public static reconstruct(
129 followId: UniqueEntityID,
130 followerId: DID,
131 targetId: string,
132 targetType: FollowTargetType,
133 createdAt: Date,
134 dateTimeOccurred: Date,
135 ): Result<UserFollowedTargetEvent> {
136 return ok(
137 new UserFollowedTargetEvent(
138 followId,
139 followerId,
140 targetId,
141 targetType,
142 createdAt,
143 dateTimeOccurred,
144 ),
145 );
146 }
147
148 getAggregateId(): UniqueEntityID {
149 return this.followId;
150 }
151}
152```
153
154---
155
156#### 1.3 Create UserUnfollowedTargetEvent
157
158**File:** `src/modules/user/domain/events/UserUnfollowedTargetEvent.ts` (NEW)
159
160**Implementation:**
161
162```typescript
163import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent';
164import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
165import { DID } from '../value-objects/DID';
166import { FollowTargetType } from '../value-objects/FollowTargetType';
167import { EventNames } from '../../../../shared/infrastructure/events/EventConfig';
168import { Result, ok } from '../../../../shared/core/Result';
169
170export class UserUnfollowedTargetEvent implements IDomainEvent {
171 public readonly eventName = EventNames.USER_UNFOLLOWED_TARGET;
172 public readonly dateTimeOccurred: Date;
173
174 private constructor(
175 public readonly followId: UniqueEntityID,
176 public readonly followerId: DID,
177 public readonly targetId: string,
178 public readonly targetType: FollowTargetType,
179 dateTimeOccurred?: Date,
180 ) {
181 this.dateTimeOccurred = dateTimeOccurred || new Date();
182 }
183
184 public static create(
185 followId: UniqueEntityID,
186 followerId: DID,
187 targetId: string,
188 targetType: FollowTargetType,
189 ): Result<UserUnfollowedTargetEvent> {
190 return ok(
191 new UserUnfollowedTargetEvent(followId, followerId, targetId, targetType),
192 );
193 }
194
195 public static reconstruct(
196 followId: UniqueEntityID,
197 followerId: DID,
198 targetId: string,
199 targetType: FollowTargetType,
200 dateTimeOccurred: Date,
201 ): Result<UserUnfollowedTargetEvent> {
202 return ok(
203 new UserUnfollowedTargetEvent(
204 followId,
205 followerId,
206 targetId,
207 targetType,
208 dateTimeOccurred,
209 ),
210 );
211 }
212
213 getAggregateId(): UniqueEntityID {
214 return this.followId;
215 }
216}
217```
218
219---
220
221#### 1.4 Update Follow Aggregate
222
223**File:** `src/modules/user/domain/Follow.ts` (MODIFY)
224
225**Update FollowProps interface to include publishedRecordId:**
226
227```typescript
228import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId';
229
230export interface FollowProps {
231 followerId: DID;
232 targetId: string;
233 targetType: FollowTargetType;
234 publishedRecordId?: PublishedRecordId; // NEW
235 createdAt: Date;
236}
237```
238
239**Add getter for publishedRecordId:**
240
241```typescript
242get publishedRecordId(): PublishedRecordId | undefined {
243 return this.props.publishedRecordId;
244}
245```
246
247**Add markAsPublished method (similar to Card):**
248
249```typescript
250public markAsPublished(publishedRecordId: PublishedRecordId): void {
251 this.props.publishedRecordId = publishedRecordId;
252}
253```
254
255**Add markForRemoval method:**
256
257```typescript
258public markForRemoval(): Result<void> {
259 const event = UserUnfollowedTargetEvent.create(
260 this.followId,
261 this.followerId,
262 this.targetId,
263 this.targetType,
264 );
265
266 if (event.isErr()) {
267 return err(new Error(event.error.message));
268 }
269
270 this.addDomainEvent(event.value);
271 return ok(undefined);
272}
273```
274
275**Update createNew method (don't raise event here - will be raised after publish in use case):**
276
277```typescript
278public static createNew(
279 followerId: DID,
280 targetId: string,
281 targetType: FollowTargetType,
282): Result<Follow> {
283 const now = new Date();
284
285 return Follow.create({
286 followerId,
287 targetId,
288 targetType,
289 createdAt: now,
290 });
291}
292```
293
294**Note:** Unlike the initial plan, we do NOT raise the UserFollowedTargetEvent in createNew().
295The event will be raised in the use case AFTER successful publishing, similar to how Card handles it.
296
297---
298
299### Phase 1.5: Event Serialization - EventMapper
300
301#### 1.5.1 Update EventMapper for Serialization/Deserialization
302
303**File:** `src/shared/infrastructure/events/EventMapper.ts` (MODIFY)
304
305**Step 1.5.1a: Add Imports**
306Add these imports at the top of the file:
307
308```typescript
309import { UserFollowedTargetEvent } from '../../../modules/user/domain/events/UserFollowedTargetEvent';
310import { UserUnfollowedTargetEvent } from '../../../modules/user/domain/events/UserUnfollowedTargetEvent';
311import { DID } from '../../../modules/user/domain/value-objects/DID';
312import { FollowTargetType } from '../../../modules/user/domain/value-objects/FollowTargetType';
313```
314
315**Step 1.5.1b: Add Serialized Interfaces**
316Add these interfaces after existing serialized event interfaces:
317
318```typescript
319export interface SerializedUserFollowedTargetEvent extends SerializedEvent {
320 eventType: typeof EventNames.USER_FOLLOWED_TARGET;
321 followId: string;
322 followerId: string;
323 targetId: string;
324 targetType: 'USER' | 'COLLECTION';
325 createdAt: string;
326}
327
328export interface SerializedUserUnfollowedTargetEvent extends SerializedEvent {
329 eventType: typeof EventNames.USER_UNFOLLOWED_TARGET;
330 followId: string;
331 followerId: string;
332 targetId: string;
333 targetType: 'USER' | 'COLLECTION';
334}
335```
336
337**Step 1.5.1c: Update SerializedEventUnion**
338Add to the SerializedEventUnion type:
339
340```typescript
341export type SerializedEventUnion =
342 | SerializedCardAddedToLibraryEvent
343 // ... other events ...
344 | SerializedUserFollowedTargetEvent
345 | SerializedUserUnfollowedTargetEvent;
346```
347
348**Step 1.5.1d: Add toSerialized() Cases**
349Add these cases in the `toSerialized()` method:
350
351```typescript
352if (event instanceof UserFollowedTargetEvent) {
353 return {
354 eventType: EventNames.USER_FOLLOWED_TARGET,
355 aggregateId: event.getAggregateId().toString(),
356 dateTimeOccurred: event.dateTimeOccurred.toISOString(),
357 followId: event.followId.toString(),
358 followerId: event.followerId.value,
359 targetId: event.targetId,
360 targetType: event.targetType.value,
361 createdAt: event.createdAt.toISOString(),
362 };
363}
364
365if (event instanceof UserUnfollowedTargetEvent) {
366 return {
367 eventType: EventNames.USER_UNFOLLOWED_TARGET,
368 aggregateId: event.getAggregateId().toString(),
369 dateTimeOccurred: event.dateTimeOccurred.toISOString(),
370 followId: event.followId.toString(),
371 followerId: event.followerId.value,
372 targetId: event.targetId,
373 targetType: event.targetType.value,
374 };
375}
376```
377
378**Step 1.5.1e: Add fromSerialized() Cases**
379Add these cases in the `fromSerialized()` method:
380
381```typescript
382case EventNames.USER_FOLLOWED_TARGET: {
383 const followId = UniqueEntityID.createFromString(eventData.followId).unwrap();
384 const followerId = DID.create(eventData.followerId).unwrap();
385 const targetType = FollowTargetType.create(eventData.targetType).unwrap();
386 const createdAt = new Date(eventData.createdAt);
387 const dateTimeOccurred = new Date(eventData.dateTimeOccurred);
388 return UserFollowedTargetEvent.reconstruct(
389 followId,
390 followerId,
391 eventData.targetId,
392 targetType,
393 createdAt,
394 dateTimeOccurred,
395 ).unwrap();
396}
397
398case EventNames.USER_UNFOLLOWED_TARGET: {
399 const followId = UniqueEntityID.createFromString(eventData.followId).unwrap();
400 const followerId = DID.create(eventData.followerId).unwrap();
401 const targetType = FollowTargetType.create(eventData.targetType).unwrap();
402 const dateTimeOccurred = new Date(eventData.dateTimeOccurred);
403 return UserUnfollowedTargetEvent.reconstruct(
404 followId,
405 followerId,
406 eventData.targetId,
407 targetType,
408 dateTimeOccurred,
409 ).unwrap();
410}
411```
412
413---
414
415### Phase 2: Repository Extensions
416
417#### 2.1 Extend IFollowsRepository Interface
418
419**File:** `src/modules/user/domain/repositories/IFollowsRepository.ts` (MODIFY)
420
421**Add these methods:**
422
423```typescript
424/**
425 * Save a follow relationship.
426 *
427 * @param follow - The follow entity to persist
428 * @returns Success or error
429 *
430 * Idempotency: Uses INSERT ON CONFLICT DO NOTHING on composite key
431 */
432save(follow: Follow): Promise<Result<void>>;
433
434/**
435 * Delete a follow relationship.
436 *
437 * @param followerId - DID of the follower
438 * @param targetId - ID of the target (user DID or collection UUID)
439 * @param targetType - Type of target
440 * @returns Success or error
441 *
442 * Idempotency: Returns success even if follow doesn't exist
443 */
444delete(
445 followerId: string,
446 targetId: string,
447 targetType: FollowTargetType,
448): Promise<Result<void>>;
449
450/**
451 * Find a specific follow relationship.
452 *
453 * @param followerId - DID of the follower
454 * @param targetId - ID of the target
455 * @param targetType - Type of target
456 * @returns Follow entity or null if not found
457 */
458findByFollowerAndTarget(
459 followerId: string,
460 targetId: string,
461 targetType: FollowTargetType,
462): Promise<Result<Follow | null>>;
463```
464
465---
466
467#### 2.2 Update Database Schema
468
469**File:** `src/modules/user/infrastructure/repositories/schema/follows.sql.ts` (MODIFY)
470
471**Add published_record_id column:**
472
473```typescript
474import { publishedRecords } from '../../../../cards/infrastructure/repositories/schema/publishedRecord.sql';
475
476export const follows = pgTable(
477 'follows',
478 {
479 follower_id: text('follower_id').notNull(),
480 target_id: text('target_id').notNull(),
481 target_type: text('target_type').notNull(),
482 published_record_id: uuid('published_record_id').references(
483 () => publishedRecords.id,
484 ), // NEW
485 created_at: timestamp('created_at').notNull().defaultNow(),
486 },
487 (table) => ({
488 pk: primaryKey(table.follower_id, table.target_id, table.target_type),
489 followerIdx: index('idx_follows_follower').on(table.follower_id),
490 targetIdx: index('idx_follows_target').on(
491 table.target_id,
492 table.target_type,
493 ),
494 }),
495);
496```
497
498**Note:** This will require a database migration. Run `npm run db:generate` after making this change.
499
500---
501
502#### 2.3 Update DrizzleFollowsRepository Implementation
503
504**File:** `src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts` (MODIFY)
505
506**Add these implementations:**
507
508```typescript
509import { PublishedRecordId } from '../../../../cards/domain/value-objects/PublishedRecordId';
510
511async save(follow: Follow): Promise<Result<void>> {
512 try {
513 // Handle publishedRecordId persistence (similar to Card repository)
514 let publishedRecordIdUuid: string | undefined;
515 if (follow.publishedRecordId) {
516 const publishedRecordId = follow.publishedRecordId.getValue();
517
518 // Insert or get existing published record
519 const existingRecord = await this.db
520 .select()
521 .from(publishedRecords)
522 .where(
523 and(
524 eq(publishedRecords.uri, publishedRecordId.uri),
525 eq(publishedRecords.cid, publishedRecordId.cid),
526 ),
527 )
528 .limit(1);
529
530 if (existingRecord.length > 0) {
531 publishedRecordIdUuid = existingRecord[0].id;
532 } else {
533 const insertResult = await this.db
534 .insert(publishedRecords)
535 .values({
536 id: randomUUID(),
537 uri: publishedRecordId.uri,
538 cid: publishedRecordId.cid,
539 })
540 .returning({ id: publishedRecords.id });
541 publishedRecordIdUuid = insertResult[0].id;
542 }
543 }
544
545 await this.db
546 .insert(follows)
547 .values({
548 follower_id: follow.followerId.value,
549 target_id: follow.targetId,
550 target_type: follow.targetType.value,
551 published_record_id: publishedRecordIdUuid,
552 created_at: follow.createdAt,
553 })
554 .onConflictDoUpdate({
555 target: [follows.follower_id, follows.target_id, follows.target_type],
556 set: {
557 published_record_id: publishedRecordIdUuid,
558 },
559 });
560
561 return ok(undefined);
562 } catch (error) {
563 return err(error as Error);
564 }
565}
566
567async delete(
568 followerId: string,
569 targetId: string,
570 targetType: FollowTargetType,
571): Promise<Result<void>> {
572 try {
573 await this.db
574 .delete(follows)
575 .where(
576 and(
577 eq(follows.follower_id, followerId),
578 eq(follows.target_id, targetId),
579 eq(follows.target_type, targetType.value),
580 ),
581 );
582
583 return ok(undefined);
584 } catch (error) {
585 return err(error as Error);
586 }
587}
588
589async findByFollowerAndTarget(
590 followerId: string,
591 targetId: string,
592 targetType: FollowTargetType,
593): Promise<Result<Follow | null>> {
594 try {
595 const results = await this.db
596 .select({
597 follow: follows,
598 publishedRecord: publishedRecords,
599 })
600 .from(follows)
601 .leftJoin(
602 publishedRecords,
603 eq(follows.published_record_id, publishedRecords.id),
604 )
605 .where(
606 and(
607 eq(follows.follower_id, followerId),
608 eq(follows.target_id, targetId),
609 eq(follows.target_type, targetType.value),
610 ),
611 )
612 .limit(1);
613
614 if (results.length === 0) {
615 return ok(null);
616 }
617
618 const row = results[0];
619 const followerDid = DID.create(row.follow.follower_id);
620 if (followerDid.isErr()) {
621 return err(followerDid.error);
622 }
623
624 const targetTypeVO = FollowTargetType.create(row.follow.target_type as 'USER' | 'COLLECTION');
625 if (targetTypeVO.isErr()) {
626 return err(targetTypeVO.error);
627 }
628
629 // Reconstruct publishedRecordId if present
630 let publishedRecordId: PublishedRecordId | undefined;
631 if (row.publishedRecord) {
632 const publishedRecordIdResult = PublishedRecordId.create({
633 uri: row.publishedRecord.uri,
634 cid: row.publishedRecord.cid,
635 });
636 if (publishedRecordIdResult.isErr()) {
637 return err(publishedRecordIdResult.error);
638 }
639 publishedRecordId = publishedRecordIdResult.value;
640 }
641
642 return Follow.create({
643 followerId: followerDid.value,
644 targetId: row.follow.target_id,
645 targetType: targetTypeVO.value,
646 publishedRecordId,
647 createdAt: row.follow.created_at,
648 });
649 } catch (error) {
650 return err(error as Error);
651 }
652}
653```
654
655---
656
657#### 2.4 Update InMemoryFollowsRepository (for tests)
658
659**File:** Find existing in-memory implementation (MODIFY)
660
661**Add these implementations:**
662
663```typescript
664private follows: Map<string, Follow> = new Map();
665
666private getKey(followerId: string, targetId: string, targetType: string): string {
667 return `${followerId}:${targetId}:${targetType}`;
668}
669
670async save(follow: Follow): Promise<Result<void>> {
671 const key = this.getKey(
672 follow.followerId.value,
673 follow.targetId,
674 follow.targetType.value,
675 );
676 this.follows.set(key, follow);
677 return ok(undefined);
678}
679
680async delete(
681 followerId: string,
682 targetId: string,
683 targetType: FollowTargetType,
684): Promise<Result<void>> {
685 const key = this.getKey(followerId, targetId, targetType.value);
686 this.follows.delete(key);
687 return ok(undefined);
688}
689
690async findByFollowerAndTarget(
691 followerId: string,
692 targetId: string,
693 targetType: FollowTargetType,
694): Promise<Result<Follow | null>> {
695 const key = this.getKey(followerId, targetId, targetType.value);
696 const follow = this.follows.get(key);
697 return ok(follow || null);
698}
699```
700
701---
702
703### Phase 3: Publisher Interface & Implementation
704
705#### 3.1 Create IFollowPublisher Interface
706
707**File:** `src/modules/user/application/ports/IFollowPublisher.ts` (NEW)
708
709**Implementation:**
710
711```typescript
712import { Result } from '../../../../shared/core/Result';
713import { UseCaseError } from '../../../../shared/core/UseCaseError';
714import { Follow } from '../../domain/Follow';
715import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId';
716import { DID } from '../../domain/value-objects/DID';
717
718export interface IFollowPublisher {
719 /**
720 * Publish a follow relationship to AT Protocol.
721 *
722 * @param follow - The Follow domain object to publish
723 * @returns Published record ID (AT URI + CID)
724 */
725 publishFollow(
726 follow: Follow,
727 ): Promise<Result<PublishedRecordId, UseCaseError>>;
728
729 /**
730 * Unpublish (delete) a follow relationship from AT Protocol.
731 *
732 * @param follow - The Follow domain object with publishedRecordId to unpublish
733 * @returns Success or error
734 */
735 unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>>;
736}
737```
738
739**Note:** Similar to ICardPublisher, this interface now accepts the Follow domain object directly
740rather than individual parameters. This follows DDD principles by keeping behavior with data.
741
742---
743
744#### 3.2 Create ATProtoFollowPublisher
745
746**File:** `src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts` (NEW)
747
748**Implementation:**
749
750```typescript
751import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher';
752import { Follow } from '../../../user/domain/Follow';
753import { Result, ok, err } from '../../../../shared/core/Result';
754import { UseCaseError } from '../../../../shared/core/UseCaseError';
755import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId';
756import { IAgentService } from '../../application/ports/IAgentService';
757import { DID } from '../../domain/DID';
758import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository';
759import { CollectionId } from '../../../cards/domain/value-objects/CollectionId';
760import { AuthenticationError } from '../../../../shared/core/AuthenticationError';
761
762export class ATProtoFollowPublisher implements IFollowPublisher {
763 constructor(
764 private readonly agentService: IAgentService,
765 private readonly followCollection: string, // 'network.cosmik.follow'
766 private readonly collectionRepository: ICollectionRepository,
767 ) {}
768
769 async publishFollow(
770 follow: Follow,
771 ): Promise<Result<PublishedRecordId, UseCaseError>> {
772 try {
773 const followerDid = DID.create(follow.followerId.value);
774 if (followerDid.isErr()) {
775 return err(
776 new UseCaseError(
777 `Invalid follower DID: ${followerDid.error.message}`,
778 ),
779 );
780 }
781
782 const agentResult = await this.agentService.getAuthenticatedAgent(
783 followerDid.value,
784 );
785
786 if (agentResult.isErr()) {
787 // Propagate authentication errors as-is
788 if (agentResult.error instanceof AuthenticationError) {
789 return err(agentResult.error);
790 }
791 return err(
792 new UseCaseError(
793 `Failed to get authenticated agent: ${agentResult.error.message}`,
794 ),
795 );
796 }
797
798 const agent = agentResult.value;
799
800 if (!agent) {
801 return err(
802 new UseCaseError('No authenticated session found for follower'),
803 );
804 }
805
806 // Determine the subject based on target type
807 let subject: string;
808 if (follow.targetType.value === 'USER') {
809 // For user follows, subject is the DID
810 subject = follow.targetId;
811 } else {
812 // For collection follows, subject is the AT URI of the published collection
813 const collectionIdResult = CollectionId.createFromString(
814 follow.targetId,
815 );
816 if (collectionIdResult.isErr()) {
817 return err(
818 new UseCaseError(
819 `Invalid collection ID: ${collectionIdResult.error.message}`,
820 ),
821 );
822 }
823
824 const collectionResult = await this.collectionRepository.findById(
825 collectionIdResult.value,
826 );
827 if (collectionResult.isErr()) {
828 return err(
829 new UseCaseError(
830 `Failed to find collection: ${collectionResult.error.message}`,
831 ),
832 );
833 }
834
835 const collection = collectionResult.value;
836 if (!collection) {
837 return err(new UseCaseError('Collection not found'));
838 }
839
840 if (!collection.publishedRecordId) {
841 return err(
842 new UseCaseError(
843 'Collection must be published before it can be followed',
844 ),
845 );
846 }
847
848 // Use the AT URI of the published collection
849 subject = collection.publishedRecordId.uri;
850 }
851
852 const record = {
853 $type: this.followCollection,
854 subject: subject,
855 createdAt: follow.createdAt.toISOString(),
856 };
857
858 const createResult = await agent.com.atproto.repo.createRecord({
859 repo: follow.followerId.value,
860 collection: this.followCollection,
861 record,
862 });
863
864 const publishedRecordId = PublishedRecordId.create({
865 uri: createResult.data.uri,
866 cid: createResult.data.cid,
867 });
868
869 if (publishedRecordId.isErr()) {
870 return err(new UseCaseError(publishedRecordId.error.message));
871 }
872
873 return ok(publishedRecordId.value);
874 } catch (error) {
875 return err(
876 new UseCaseError(
877 `Failed to publish follow: ${error instanceof Error ? error.message : 'Unknown error'}`,
878 ),
879 );
880 }
881 }
882
883 async unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>> {
884 try {
885 if (!follow.publishedRecordId) {
886 // Already unpublished or never published
887 return ok(undefined);
888 }
889
890 const followerDid = DID.create(follow.followerId.value);
891 if (followerDid.isErr()) {
892 return err(
893 new UseCaseError(
894 `Invalid follower DID: ${followerDid.error.message}`,
895 ),
896 );
897 }
898
899 const agentResult = await this.agentService.getAuthenticatedAgent(
900 followerDid.value,
901 );
902
903 if (agentResult.isErr()) {
904 // Propagate authentication errors as-is
905 if (agentResult.error instanceof AuthenticationError) {
906 return err(agentResult.error);
907 }
908 return err(
909 new UseCaseError(
910 `Failed to get authenticated agent: ${agentResult.error.message}`,
911 ),
912 );
913 }
914
915 const agent = agentResult.value;
916
917 if (!agent) {
918 return err(
919 new UseCaseError('No authenticated session found for follower'),
920 );
921 }
922
923 // Extract rkey from AT URI (format: at://did/collection/rkey)
924 const uriParts = follow.publishedRecordId.uri.split('/');
925 const rkey = uriParts[uriParts.length - 1];
926
927 await agent.com.atproto.repo.deleteRecord({
928 repo: follow.followerId.value,
929 collection: this.followCollection,
930 rkey,
931 });
932
933 return ok(undefined);
934 } catch (error) {
935 return err(
936 new UseCaseError(
937 `Failed to unpublish follow: ${error instanceof Error ? error.message : 'Unknown error'}`,
938 ),
939 );
940 }
941 }
942}
943```
944
945---
946
947#### 3.3 Create FakeFollowPublisher (for tests)
948
949**File:** `src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts` (NEW)
950
951**Implementation:**
952
953```typescript
954import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher';
955import { Follow } from '../../../user/domain/Follow';
956import { Result, ok } from '../../../../shared/core/Result';
957import { UseCaseError } from '../../../../shared/core/UseCaseError';
958import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId';
959
960export class FakeFollowPublisher implements IFollowPublisher {
961 private publishedFollows: Map<string, PublishedRecordId> = new Map();
962
963 async publishFollow(
964 follow: Follow,
965 ): Promise<Result<PublishedRecordId, UseCaseError>> {
966 const key = `${follow.followerId.value}:${follow.targetId}`;
967 const fakeUri = `at://${follow.followerId.value}/network.cosmik.follow/${Date.now()}`;
968 const fakeCid = `fake-cid-${Date.now()}`;
969
970 const recordId = PublishedRecordId.create({
971 uri: fakeUri,
972 cid: fakeCid,
973 }).unwrap();
974
975 this.publishedFollows.set(key, recordId);
976
977 return ok(recordId);
978 }
979
980 async unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>> {
981 if (!follow.publishedRecordId) {
982 return ok(undefined);
983 }
984
985 // Find and remove from map
986 for (const [key, value] of this.publishedFollows.entries()) {
987 if (value.uri === follow.publishedRecordId.uri) {
988 this.publishedFollows.delete(key);
989 break;
990 }
991 }
992
993 return ok(undefined);
994 }
995
996 // Test helper
997 getPublishedFollows(): Map<string, PublishedRecordId> {
998 return this.publishedFollows;
999 }
1000}
1001```
1002
1003---
1004
1005#### 3.4 Add Follow Publisher to ServiceFactory
1006
1007**File:** `src/shared/infrastructure/http/factories/ServiceFactory.ts` (MODIFY)
1008
1009**Add to Services interface:**
1010
1011```typescript
1012export interface Services extends SharedServices {
1013 // ... existing services ...
1014 followPublisher: IFollowPublisher;
1015}
1016```
1017
1018**Add to create() method:**
1019
1020```typescript
1021const followPublisher = useFakePublishers
1022 ? new FakeFollowPublisher()
1023 : new ATProtoFollowPublisher(
1024 atProtoAgentService,
1025 collections.follow,
1026 repositories.collectionRepository, // Need collection repository for AT URI resolution
1027 );
1028
1029return {
1030 // ... existing services ...
1031 followPublisher,
1032};
1033```
1034
1035---
1036
1037#### 3.5 Update Environment Config
1038
1039**File:** `src/shared/infrastructure/config/EnvironmentConfigService.ts` (MODIFY)
1040
1041**Update getAtProtoCollections():**
1042
1043```typescript
1044getAtProtoCollections() {
1045 return {
1046 card: 'network.cosmik.card',
1047 collection: 'network.cosmik.collection',
1048 collectionLink: 'network.cosmik.collectionLink',
1049 collectionLinkRemoval: 'network.cosmik.collectionLinkRemoval',
1050 follow: 'network.cosmik.follow', // NEW
1051 };
1052}
1053```
1054
1055---
1056
1057#### 3.6 Configure Queue Routing
1058
1059**File:** `src/shared/infrastructure/events/BullMQEventPublisher.ts` (MODIFY)
1060
1061**Purpose:** Route follow/unfollow events to the correct queue for processing.
1062
1063Update the `getTargetQueues()` method to include routing for the new events:
1064
1065```typescript
1066private getTargetQueues(eventName: EventName): QueueName[] {
1067 switch (eventName) {
1068 // ... existing cases ...
1069 case EventNames.USER_FOLLOWED_TARGET:
1070 return [QueueNames.NOTIFICATIONS]; // Route to notifications queue
1071 case EventNames.USER_UNFOLLOWED_TARGET:
1072 return [QueueNames.NOTIFICATIONS]; // Route to notifications queue
1073 default:
1074 return [QueueNames.FEEDS];
1075 }
1076}
1077```
1078
1079**Why NOTIFICATIONS queue?**
1080
1081- Follow events trigger user notifications
1082- The NotificationWorkerProcess subscribes to this queue
1083- Keeps notification events separate from feed generation events
1084
1085---
1086
1087### Phase 4: Application Layer - Use Cases
1088
1089#### 4.1 Create FollowTargetUseCase
1090
1091**File:** `src/modules/user/application/useCases/commands/FollowTargetUseCase.ts` (NEW)
1092
1093**Full implementation:**
1094
1095```typescript
1096import { Result, ok, err } from '../../../../../shared/core/Result';
1097import { BaseUseCase } from '../../../../../shared/core/UseCase';
1098import { UseCaseError } from '../../../../../shared/core/UseCaseError';
1099import { AppError } from '../../../../../shared/core/AppError';
1100import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher';
1101import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository';
1102import { IUserRepository } from '../../../domain/repositories/IUserRepository';
1103import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository';
1104import { IFollowPublisher } from '../../ports/IFollowPublisher';
1105import { DID } from '../../../domain/value-objects/DID';
1106import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType';
1107import { Follow } from '../../../domain/Follow';
1108import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId';
1109
1110export interface FollowTargetDTO {
1111 followerId: string; // DID
1112 targetId: string; // DID or Collection UUID
1113 targetType: 'USER' | 'COLLECTION';
1114}
1115
1116export interface FollowTargetResponseDTO {
1117 followId: string;
1118}
1119
1120export class ValidationError extends UseCaseError {
1121 constructor(message: string) {
1122 super(message);
1123 }
1124}
1125
1126export class FollowTargetUseCase extends BaseUseCase<
1127 FollowTargetDTO,
1128 Result<FollowTargetResponseDTO, ValidationError | AppError.UnexpectedError>
1129> {
1130 constructor(
1131 private followsRepository: IFollowsRepository,
1132 private userRepository: IUserRepository,
1133 private collectionRepository: ICollectionRepository,
1134 private followPublisher: IFollowPublisher,
1135 eventPublisher: IEventPublisher,
1136 ) {
1137 super(eventPublisher);
1138 }
1139
1140 async execute(
1141 request: FollowTargetDTO,
1142 ): Promise<
1143 Result<FollowTargetResponseDTO, ValidationError | AppError.UnexpectedError>
1144 > {
1145 try {
1146 // 1. Validate followerId (create DID value object)
1147 const followerDidResult = DID.create(request.followerId);
1148 if (followerDidResult.isErr()) {
1149 return err(
1150 new ValidationError(
1151 `Invalid follower ID: ${followerDidResult.error.message}`,
1152 ),
1153 );
1154 }
1155 const followerDid = followerDidResult.value;
1156
1157 // 2. Validate targetType
1158 const targetTypeResult = FollowTargetType.create(request.targetType);
1159 if (targetTypeResult.isErr()) {
1160 return err(
1161 new ValidationError(
1162 `Invalid target type: ${targetTypeResult.error.message}`,
1163 ),
1164 );
1165 }
1166 const targetType = targetTypeResult.value;
1167
1168 // 3. Prevent self-follows (only for USER type)
1169 if (
1170 targetType.value === 'USER' &&
1171 request.followerId === request.targetId
1172 ) {
1173 return err(new ValidationError('Users cannot follow themselves'));
1174 }
1175
1176 // 4. Validate target exists
1177 if (targetType.value === 'USER') {
1178 const targetDidResult = DID.create(request.targetId);
1179 if (targetDidResult.isErr()) {
1180 return err(
1181 new ValidationError(
1182 `Invalid target ID: ${targetDidResult.error.message}`,
1183 ),
1184 );
1185 }
1186
1187 const userResult = await this.userRepository.findByDID(
1188 targetDidResult.value,
1189 );
1190 if (userResult.isErr()) {
1191 return err(AppError.UnexpectedError.create(userResult.error));
1192 }
1193
1194 if (!userResult.value) {
1195 return err(new ValidationError('Target user not found'));
1196 }
1197 } else if (targetType.value === 'COLLECTION') {
1198 const collectionIdResult = CollectionId.createFromString(
1199 request.targetId,
1200 );
1201 if (collectionIdResult.isErr()) {
1202 return err(
1203 new ValidationError(
1204 `Invalid collection ID: ${collectionIdResult.error.message}`,
1205 ),
1206 );
1207 }
1208
1209 const collectionResult = await this.collectionRepository.findById(
1210 collectionIdResult.value,
1211 );
1212 if (collectionResult.isErr()) {
1213 return err(AppError.UnexpectedError.create(collectionResult.error));
1214 }
1215
1216 if (!collectionResult.value) {
1217 return err(new ValidationError('Target collection not found'));
1218 }
1219 }
1220
1221 // 5. Check if already following (idempotent)
1222 const existingFollowResult =
1223 await this.followsRepository.findByFollowerAndTarget(
1224 request.followerId,
1225 request.targetId,
1226 targetType,
1227 );
1228
1229 if (existingFollowResult.isErr()) {
1230 return err(AppError.UnexpectedError.create(existingFollowResult.error));
1231 }
1232
1233 if (existingFollowResult.value) {
1234 // Already following - return success with existing follow ID
1235 return ok({
1236 followId: existingFollowResult.value.followId.toString(),
1237 });
1238 }
1239
1240 // 6. Create Follow aggregate (does NOT raise event yet)
1241 const followResult = Follow.createNew(
1242 followerDid,
1243 request.targetId,
1244 targetType,
1245 );
1246
1247 if (followResult.isErr()) {
1248 return err(new ValidationError(followResult.error.message));
1249 }
1250
1251 let follow = followResult.value;
1252
1253 // 7. Publish to AT Protocol BEFORE saving
1254 const publishResult = await this.followPublisher.publishFollow(follow);
1255
1256 if (publishResult.isErr()) {
1257 // Propagate authentication errors
1258 if (publishResult.error instanceof AuthenticationError) {
1259 return err(publishResult.error);
1260 }
1261 if (publishResult.error instanceof AppError.UnexpectedError) {
1262 return err(publishResult.error);
1263 }
1264 return err(new ValidationError(publishResult.error.message));
1265 }
1266
1267 // 8. Mark follow as published with the returned publishedRecordId
1268 const publishedRecordId = publishResult.value;
1269 follow.markAsPublished(publishedRecordId);
1270
1271 // 9. Save to repository with publishedRecordId
1272 const saveResult = await this.followsRepository.save(follow);
1273 if (saveResult.isErr()) {
1274 return err(AppError.UnexpectedError.create(saveResult.error));
1275 }
1276
1277 // 10. Raise UserFollowedTargetEvent (now that publish succeeded)
1278 const event = UserFollowedTargetEvent.create(
1279 follow.followId,
1280 follow.followerId,
1281 follow.targetId,
1282 follow.targetType,
1283 follow.createdAt,
1284 );
1285
1286 if (event.isErr()) {
1287 console.error('Failed to create domain event:', event.error);
1288 } else {
1289 follow.addDomainEvent(event.value);
1290 }
1291
1292 // 11. Publish domain events
1293 const publishEventsResult = await this.publishEventsForAggregate(follow);
1294 if (publishEventsResult.isErr()) {
1295 console.error(
1296 'Failed to publish domain events:',
1297 publishEventsResult.error,
1298 );
1299 // Don't fail the operation
1300 }
1301
1302 // 12. Return success
1303 return ok({
1304 followId: follow.followId.toString(),
1305 });
1306 } catch (error) {
1307 return err(AppError.UnexpectedError.create(error));
1308 }
1309 }
1310}
1311```
1312
1313---
1314
1315#### 4.2 Create UnfollowTargetUseCase
1316
1317**File:** `src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts` (NEW)
1318
1319**Full implementation:**
1320
1321```typescript
1322import { Result, ok, err } from '../../../../../shared/core/Result';
1323import { BaseUseCase } from '../../../../../shared/core/UseCase';
1324import { UseCaseError } from '../../../../../shared/core/UseCaseError';
1325import { AppError } from '../../../../../shared/core/AppError';
1326import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher';
1327import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository';
1328import { IFollowPublisher } from '../../ports/IFollowPublisher';
1329import { DID } from '../../../domain/value-objects/DID';
1330import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType';
1331
1332export interface UnfollowTargetDTO {
1333 followerId: string; // DID
1334 targetId: string; // DID or Collection UUID
1335 targetType: 'USER' | 'COLLECTION';
1336}
1337
1338export class ValidationError extends UseCaseError {
1339 constructor(message: string) {
1340 super(message);
1341 }
1342}
1343
1344export class UnfollowTargetUseCase extends BaseUseCase<
1345 UnfollowTargetDTO,
1346 Result<void, ValidationError | AppError.UnexpectedError>
1347> {
1348 constructor(
1349 private followsRepository: IFollowsRepository,
1350 private followPublisher: IFollowPublisher,
1351 eventPublisher: IEventPublisher,
1352 ) {
1353 super(eventPublisher);
1354 }
1355
1356 async execute(
1357 request: UnfollowTargetDTO,
1358 ): Promise<Result<void, ValidationError | AppError.UnexpectedError>> {
1359 try {
1360 // 1. Validate followerId (create DID value object)
1361 const followerDidResult = DID.create(request.followerId);
1362 if (followerDidResult.isErr()) {
1363 return err(
1364 new ValidationError(
1365 `Invalid follower ID: ${followerDidResult.error.message}`,
1366 ),
1367 );
1368 }
1369 const followerDid = followerDidResult.value;
1370
1371 // 2. Validate targetType
1372 const targetTypeResult = FollowTargetType.create(request.targetType);
1373 if (targetTypeResult.isErr()) {
1374 return err(
1375 new ValidationError(
1376 `Invalid target type: ${targetTypeResult.error.message}`,
1377 ),
1378 );
1379 }
1380 const targetType = targetTypeResult.value;
1381
1382 // 3. Find existing follow record
1383 const existingFollowResult =
1384 await this.followsRepository.findByFollowerAndTarget(
1385 request.followerId,
1386 request.targetId,
1387 targetType,
1388 );
1389
1390 if (existingFollowResult.isErr()) {
1391 return err(AppError.UnexpectedError.create(existingFollowResult.error));
1392 }
1393
1394 // 4. If not found, return success (idempotent)
1395 if (!existingFollowResult.value) {
1396 return ok(undefined);
1397 }
1398
1399 const follow = existingFollowResult.value;
1400
1401 // 5. Unpublish from AT Protocol (if has publishedRecordId)
1402 if (follow.publishedRecordId) {
1403 const unpublishResult =
1404 await this.followPublisher.unpublishFollow(follow);
1405 if (unpublishResult.isErr()) {
1406 // Log but don't fail - we still want to delete locally
1407 console.error(
1408 'Failed to unpublish follow from AT Protocol:',
1409 unpublishResult.error,
1410 );
1411 }
1412 }
1413
1414 // 6. Call markForRemoval() (raises UserUnfollowedTargetEvent)
1415 const markResult = follow.markForRemoval();
1416 if (markResult.isErr()) {
1417 return err(new ValidationError(markResult.error.message));
1418 }
1419
1420 // 7. Delete from repository
1421 const deleteResult = await this.followsRepository.delete(
1422 request.followerId,
1423 request.targetId,
1424 targetType,
1425 );
1426
1427 if (deleteResult.isErr()) {
1428 return err(AppError.UnexpectedError.create(deleteResult.error));
1429 }
1430
1431 // 8. Publish domain events
1432 const publishEventsResult = await this.publishEventsForAggregate(follow);
1433 if (publishEventsResult.isErr()) {
1434 console.error(
1435 'Failed to publish domain events:',
1436 publishEventsResult.error,
1437 );
1438 // Don't fail the operation
1439 }
1440
1441 // 9. Return success
1442 return ok(undefined);
1443 } catch (error) {
1444 return err(AppError.UnexpectedError.create(error));
1445 }
1446 }
1447}
1448```
1449
1450---
1451
1452#### 4.3 Register Use Cases in UseCaseFactory
1453
1454**File:** `src/shared/infrastructure/http/factories/UseCaseFactory.ts` (MODIFY)
1455
1456**Add to UseCases interface:**
1457
1458```typescript
1459export interface UseCases {
1460 // ... existing use cases ...
1461 followTargetUseCase: FollowTargetUseCase;
1462 unfollowTargetUseCase: UnfollowTargetUseCase;
1463}
1464```
1465
1466**Add to createForWebApp():**
1467
1468```typescript
1469import { FollowTargetUseCase } from '../../../modules/user/application/useCases/commands/FollowTargetUseCase';
1470import { UnfollowTargetUseCase } from '../../../modules/user/application/useCases/commands/UnfollowTargetUseCase';
1471
1472// Inside createForWebApp method:
1473followTargetUseCase: new FollowTargetUseCase(
1474 repositories.followsRepository,
1475 repositories.userRepository,
1476 repositories.collectionRepository,
1477 services.followPublisher,
1478 services.eventPublisher,
1479),
1480unfollowTargetUseCase: new UnfollowTargetUseCase(
1481 repositories.followsRepository,
1482 services.followPublisher,
1483 services.eventPublisher,
1484),
1485```
1486
1487---
1488
1489### Phase 5: Notification System
1490
1491#### 5.1 Extend NotificationType Enum
1492
1493**File:** `src/types/src/api/responses.ts` (MODIFY)
1494
1495**Add to enum:**
1496
1497```typescript
1498export enum NotificationType {
1499 USER_ADDED_YOUR_CARD = 'USER_ADDED_YOUR_CARD',
1500 USER_ADDED_YOUR_BSKY_POST = 'USER_ADDED_YOUR_BSKY_POST',
1501 USER_ADDED_YOUR_COLLECTION = 'USER_ADDED_YOUR_COLLECTION',
1502 USER_ADDED_TO_YOUR_COLLECTION = 'USER_ADDED_TO_YOUR_COLLECTION',
1503 USER_FOLLOWED_YOU = 'USER_FOLLOWED_YOU', // NEW
1504 USER_FOLLOWED_YOUR_COLLECTION = 'USER_FOLLOWED_YOUR_COLLECTION', // NEW
1505}
1506```
1507
1508---
1509
1510#### 5.2 Extend Notification Domain Entity
1511
1512**File:** `src/modules/notifications/domain/Notification.ts` (MODIFY)
1513
1514**Add metadata interface (if not already extended):**
1515
1516```typescript
1517export interface FollowNotificationMetadata {
1518 targetType: 'USER' | 'COLLECTION';
1519 targetId?: string; // Collection ID if applicable
1520}
1521```
1522
1523**Add factory methods:**
1524
1525```typescript
1526public static createUserFollowedYou(
1527 recipientUserId: CuratorId,
1528 actorUserId: CuratorId,
1529): Result<Notification> {
1530 const metadata: FollowNotificationMetadata = {
1531 targetType: 'USER',
1532 };
1533
1534 return Notification.create({
1535 recipientUserId,
1536 actorUserId,
1537 type: NotificationType.USER_FOLLOWED_YOU,
1538 metadata: metadata as any,
1539 read: false,
1540 createdAt: new Date(),
1541 updatedAt: new Date(),
1542 });
1543}
1544
1545public static createUserFollowedYourCollection(
1546 recipientUserId: CuratorId,
1547 actorUserId: CuratorId,
1548 collectionId: CollectionId,
1549): Result<Notification> {
1550 const metadata: FollowNotificationMetadata = {
1551 targetType: 'COLLECTION',
1552 targetId: collectionId.getStringValue(),
1553 };
1554
1555 return Notification.create({
1556 recipientUserId,
1557 actorUserId,
1558 type: NotificationType.USER_FOLLOWED_YOUR_COLLECTION,
1559 metadata: metadata as any,
1560 read: false,
1561 createdAt: new Date(),
1562 updatedAt: new Date(),
1563 });
1564}
1565```
1566
1567---
1568
1569#### 5.3 Extend NotificationService
1570
1571**File:** `src/modules/notifications/domain/services/NotificationService.ts` (MODIFY)
1572
1573**Add methods:**
1574
1575```typescript
1576async createUserFollowedYouNotification(
1577 recipientUserId: CuratorId,
1578 actorUserId: CuratorId,
1579): Promise<Result<Notification, NotificationServiceError>> {
1580 // Don't create notification if user is following themselves (shouldn't happen)
1581 if (recipientUserId.equals(actorUserId)) {
1582 return err(
1583 new NotificationServiceError(
1584 'Cannot notify user about their own action',
1585 ),
1586 );
1587 }
1588
1589 const notificationResult = Notification.createUserFollowedYou(
1590 recipientUserId,
1591 actorUserId,
1592 );
1593
1594 if (notificationResult.isErr()) {
1595 return err(
1596 new NotificationServiceError(notificationResult.error.message),
1597 );
1598 }
1599
1600 const notification = notificationResult.value;
1601
1602 const saveResult = await this.notificationRepository.save(notification);
1603
1604 if (saveResult.isErr()) {
1605 return err(new NotificationServiceError(saveResult.error.message));
1606 }
1607
1608 return ok(notification);
1609}
1610
1611async createUserFollowedYourCollectionNotification(
1612 recipientUserId: CuratorId,
1613 actorUserId: CuratorId,
1614 collectionId: CollectionId,
1615): Promise<Result<Notification, NotificationServiceError>> {
1616 // Don't create notification if user is following their own collection
1617 if (recipientUserId.equals(actorUserId)) {
1618 return err(
1619 new NotificationServiceError(
1620 'Cannot notify user about their own action',
1621 ),
1622 );
1623 }
1624
1625 const notificationResult = Notification.createUserFollowedYourCollection(
1626 recipientUserId,
1627 actorUserId,
1628 collectionId,
1629 );
1630
1631 if (notificationResult.isErr()) {
1632 return err(
1633 new NotificationServiceError(notificationResult.error.message),
1634 );
1635 }
1636
1637 const notification = notificationResult.value;
1638
1639 const saveResult = await this.notificationRepository.save(notification);
1640
1641 if (saveResult.isErr()) {
1642 return err(new NotificationServiceError(saveResult.error.message));
1643 }
1644
1645 return ok(notification);
1646}
1647```
1648
1649---
1650
1651#### 5.4 Create Event Handler for Notifications
1652
1653**File:** `src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts` (NEW)
1654
1655**Implementation:**
1656
1657```typescript
1658import { IEventHandler } from '../../../../shared/application/events/IEventHandler';
1659import { UserFollowedTargetEvent } from '../../../user/domain/events/UserFollowedTargetEvent';
1660import { Result, ok, err } from '../../../../shared/core/Result';
1661import { NotificationService } from '../../domain/services/NotificationService';
1662import { IUserRepository } from '../../../user/domain/repositories/IUserRepository';
1663import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository';
1664import { CuratorId } from '../../../cards/domain/value-objects/CuratorId';
1665import { CollectionId } from '../../../cards/domain/value-objects/CollectionId';
1666
1667export class UserFollowedTargetEventHandler
1668 implements IEventHandler<UserFollowedTargetEvent>
1669{
1670 constructor(
1671 private notificationService: NotificationService,
1672 private userRepository: IUserRepository,
1673 private collectionRepository: ICollectionRepository,
1674 ) {}
1675
1676 async handle(event: UserFollowedTargetEvent): Promise<Result<void>> {
1677 try {
1678 const actorIdResult = CuratorId.create(event.followerId.value);
1679 if (actorIdResult.isErr()) {
1680 console.error('Invalid actor ID:', actorIdResult.error);
1681 return err(actorIdResult.error);
1682 }
1683 const actorId = actorIdResult.value;
1684
1685 if (event.targetType.value === 'USER') {
1686 // User followed another user - notify the followed user
1687 const recipientIdResult = CuratorId.create(event.targetId);
1688 if (recipientIdResult.isErr()) {
1689 console.error('Invalid recipient ID:', recipientIdResult.error);
1690 return err(recipientIdResult.error);
1691 }
1692 const recipientId = recipientIdResult.value;
1693
1694 // Skip if user is following themselves (shouldn't happen due to validation)
1695 if (actorId.equals(recipientId)) {
1696 return ok(undefined);
1697 }
1698
1699 const notificationResult =
1700 await this.notificationService.createUserFollowedYouNotification(
1701 recipientId,
1702 actorId,
1703 );
1704
1705 if (notificationResult.isErr()) {
1706 console.error(
1707 'Failed to create user followed notification:',
1708 notificationResult.error,
1709 );
1710 return err(notificationResult.error);
1711 }
1712 } else if (event.targetType.value === 'COLLECTION') {
1713 // User followed a collection - notify the collection author
1714 const collectionIdResult = CollectionId.createFromString(
1715 event.targetId,
1716 );
1717 if (collectionIdResult.isErr()) {
1718 console.error('Invalid collection ID:', collectionIdResult.error);
1719 return err(collectionIdResult.error);
1720 }
1721 const collectionId = collectionIdResult.value;
1722
1723 const collectionResult =
1724 await this.collectionRepository.findById(collectionId);
1725 if (collectionResult.isErr()) {
1726 console.error('Failed to find collection:', collectionResult.error);
1727 return err(collectionResult.error);
1728 }
1729
1730 const collection = collectionResult.value;
1731 if (!collection) {
1732 console.warn(
1733 'Collection not found for notification:',
1734 event.targetId,
1735 );
1736 return ok(undefined);
1737 }
1738
1739 const recipientId = collection.authorId;
1740
1741 // Skip if user is following their own collection
1742 if (actorId.equals(recipientId)) {
1743 return ok(undefined);
1744 }
1745
1746 const notificationResult =
1747 await this.notificationService.createUserFollowedYourCollectionNotification(
1748 recipientId,
1749 actorId,
1750 collectionId,
1751 );
1752
1753 if (notificationResult.isErr()) {
1754 console.error(
1755 'Failed to create collection followed notification:',
1756 notificationResult.error,
1757 );
1758 return err(notificationResult.error);
1759 }
1760 }
1761
1762 return ok(undefined);
1763 } catch (error) {
1764 console.error('Error handling UserFollowedTargetEvent:', error);
1765 return err(error as Error);
1766 }
1767 }
1768}
1769```
1770
1771---
1772
1773#### 5.5 Register Event Handler in Worker Process
1774
1775**File:** `src/shared/infrastructure/processes/NotificationWorkerProcess.ts` (MODIFY)
1776
1777**Add to registerHandlers method:**
1778
1779```typescript
1780import { UserFollowedTargetEventHandler } from '../../../modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler';
1781import { EventNames } from '../events/EventConfig';
1782
1783// Inside registerHandlers method:
1784const userFollowedTargetHandler = new UserFollowedTargetEventHandler(
1785 services.notificationService,
1786 repositories.userRepository,
1787 repositories.collectionRepository,
1788);
1789
1790await subscriber.subscribe(
1791 EventNames.USER_FOLLOWED_TARGET,
1792 userFollowedTargetHandler,
1793);
1794```
1795
1796---
1797
1798### Phase 6: HTTP Layer - Controllers
1799
1800#### 6.1 Create FollowTargetController
1801
1802**File:** `src/modules/user/infrastructure/http/controllers/FollowTargetController.ts` (NEW)
1803
1804**Implementation:**
1805
1806```typescript
1807import { Controller } from '../../../../../shared/infrastructure/http/Controller';
1808import { Response } from 'express';
1809import { FollowTargetUseCase } from '../../../application/useCases/commands/FollowTargetUseCase';
1810import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware';
1811import { AuthenticationError } from '../../../../../shared/core/AuthenticationError';
1812
1813export class FollowTargetController extends Controller {
1814 constructor(private followTargetUseCase: FollowTargetUseCase) {
1815 super();
1816 }
1817
1818 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> {
1819 try {
1820 const { targetId, targetType } = req.body;
1821 const followerId = req.did;
1822
1823 if (!followerId) {
1824 return this.unauthorized(res);
1825 }
1826
1827 if (!targetId || !targetType) {
1828 return this.badRequest(res, 'Target ID and type are required');
1829 }
1830
1831 const result = await this.followTargetUseCase.execute({
1832 followerId,
1833 targetId,
1834 targetType,
1835 });
1836
1837 if (result.isErr()) {
1838 if (result.error instanceof AuthenticationError) {
1839 return this.unauthorized(res, result.error.message);
1840 }
1841 return this.fail(res, result.error);
1842 }
1843
1844 return this.ok(res, result.value);
1845 } catch (error: any) {
1846 return this.handleError(res, error);
1847 }
1848 }
1849}
1850```
1851
1852---
1853
1854#### 6.2 Create UnfollowTargetController
1855
1856**File:** `src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts` (NEW)
1857
1858**Implementation:**
1859
1860```typescript
1861import { Controller } from '../../../../../shared/infrastructure/http/Controller';
1862import { Response } from 'express';
1863import { UnfollowTargetUseCase } from '../../../application/useCases/commands/UnfollowTargetUseCase';
1864import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware';
1865import { AuthenticationError } from '../../../../../shared/core/AuthenticationError';
1866
1867export class UnfollowTargetController extends Controller {
1868 constructor(private unfollowTargetUseCase: UnfollowTargetUseCase) {
1869 super();
1870 }
1871
1872 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> {
1873 try {
1874 const { targetId, targetType } = req.params;
1875 const followerId = req.did;
1876
1877 if (!followerId) {
1878 return this.unauthorized(res);
1879 }
1880
1881 if (!targetId || !targetType) {
1882 return this.badRequest(res, 'Target ID and type are required');
1883 }
1884
1885 const result = await this.unfollowTargetUseCase.execute({
1886 followerId,
1887 targetId,
1888 targetType: targetType as 'USER' | 'COLLECTION',
1889 });
1890
1891 if (result.isErr()) {
1892 if (result.error instanceof AuthenticationError) {
1893 return this.unauthorized(res, result.error.message);
1894 }
1895 return this.fail(res, result.error);
1896 }
1897
1898 return this.noContent(res);
1899 } catch (error: any) {
1900 return this.handleError(res, error);
1901 }
1902 }
1903}
1904```
1905
1906---
1907
1908### Phase 7: HTTP Layer - Routes
1909
1910#### 7.1 Add Follow/Unfollow Routes
1911
1912**File:** `src/modules/user/infrastructure/http/routes/userRoutes.ts` (MODIFY)
1913
1914**Implementation:**
1915
1916```typescript
1917import { Router } from 'express';
1918import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware';
1919import { FollowTargetController } from '../controllers/FollowTargetController';
1920import { UnfollowTargetController } from '../controllers/UnfollowTargetController';
1921
1922export function createUserRoutes(
1923 authMiddleware: AuthMiddleware,
1924 followTargetController: FollowTargetController,
1925 unfollowTargetController: UnfollowTargetController,
1926 // ... other controllers ...
1927): Router {
1928 const router = Router();
1929
1930 // Follow/Unfollow routes
1931 router.post('/follows', authMiddleware.ensureAuthenticated(), (req, res) =>
1932 followTargetController.execute(req, res),
1933 );
1934
1935 router.delete(
1936 '/follows/:targetId/:targetType',
1937 authMiddleware.ensureAuthenticated(),
1938 (req, res) => unfollowTargetController.execute(req, res),
1939 );
1940
1941 // ... existing routes ...
1942
1943 return router;
1944}
1945```
1946
1947**API Endpoints:**
1948
1949- `POST /api/users/follows` - Follow a user or collection
1950- `DELETE /api/users/follows/:targetId/:targetType` - Unfollow a user or collection
1951
1952---
1953
1954### Phase 8: API Types
1955
1956#### 8.1 Add Request Types
1957
1958**File:** `src/types/src/api/requests.ts` (MODIFY)
1959
1960**Add these interfaces:**
1961
1962```typescript
1963export interface FollowTargetRequest {
1964 targetId: string; // DID or Collection UUID
1965 targetType: 'USER' | 'COLLECTION';
1966}
1967
1968export interface UnfollowTargetRequest {
1969 targetId: string;
1970 targetType: 'USER' | 'COLLECTION';
1971}
1972```
1973
1974---
1975
1976#### 8.2 Add Response Types
1977
1978**File:** `src/types/src/api/responses.ts` (MODIFY)
1979
1980**Add these interfaces:**
1981
1982```typescript
1983export interface FollowTargetResponse {
1984 followId: string;
1985}
1986
1987// Optional: Response types for getting follows
1988export interface GetFollowsParams extends PaginationParams {
1989 targetType?: 'USER' | 'COLLECTION';
1990}
1991
1992export interface FollowDTO {
1993 followId: string;
1994 followerId: string;
1995 targetId: string;
1996 targetType: 'USER' | 'COLLECTION';
1997 createdAt: string;
1998}
1999
2000export interface GetFollowsResponse {
2001 follows: FollowDTO[];
2002 pagination: Pagination;
2003}
2004```
2005
2006---
2007
2008### Phase 9: API Client
2009
2010#### 9.1 Extend UserClient
2011
2012**File:** `src/webapp/api-client/clients/UserClient.ts` (MODIFY)
2013
2014**Add these methods:**
2015
2016```typescript
2017import { FollowTargetRequest, FollowTargetResponse } from '@semble/types';
2018
2019export class UserClient extends BaseClient {
2020 // ... existing methods ...
2021
2022 async followTarget(
2023 request: FollowTargetRequest,
2024 ): Promise<FollowTargetResponse> {
2025 return this.request<FollowTargetResponse>(
2026 'POST',
2027 '/api/users/follows',
2028 request,
2029 );
2030 }
2031
2032 async unfollowTarget(
2033 targetId: string,
2034 targetType: 'USER' | 'COLLECTION',
2035 ): Promise<void> {
2036 return this.request<void>(
2037 'DELETE',
2038 `/api/users/follows/${targetId}/${targetType}`,
2039 );
2040 }
2041
2042 // Optional: Get follows for a user
2043 async getFollows(params?: GetFollowsParams): Promise<GetFollowsResponse> {
2044 return this.request<GetFollowsResponse>(
2045 'GET',
2046 '/api/users/follows',
2047 undefined,
2048 params,
2049 );
2050 }
2051}
2052```
2053
2054---
2055
2056#### 9.2 Update ApiClient
2057
2058**File:** `src/webapp/api-client/ApiClient.ts` (MODIFY)
2059
2060**Add delegation methods:**
2061
2062```typescript
2063export class ApiClient {
2064 // ... existing properties ...
2065 private userClient: UserClient;
2066
2067 // ... constructor ...
2068
2069 // Add delegation methods
2070 async followTarget(
2071 request: FollowTargetRequest,
2072 ): Promise<FollowTargetResponse> {
2073 return this.userClient.followTarget(request);
2074 }
2075
2076 async unfollowTarget(
2077 targetId: string,
2078 targetType: 'USER' | 'COLLECTION',
2079 ): Promise<void> {
2080 return this.userClient.unfollowTarget(targetId, targetType);
2081 }
2082
2083 async getFollows(params?: GetFollowsParams): Promise<GetFollowsResponse> {
2084 return this.userClient.getFollows(params);
2085 }
2086}
2087```
2088
2089---
2090
2091### Phase 10: Factory & Route Registration
2092
2093#### 10.1 Update ControllerFactory
2094
2095**File:** `src/shared/infrastructure/http/factories/ControllerFactory.ts` (MODIFY)
2096
2097**Update the interface:**
2098
2099```typescript
2100export interface Controllers {
2101 // ... existing controllers ...
2102 followTargetController: FollowTargetController;
2103 unfollowTargetController: UnfollowTargetController;
2104}
2105```
2106
2107**Add controller instantiation:**
2108
2109```typescript
2110import { FollowTargetController } from '../../../modules/user/infrastructure/http/controllers/FollowTargetController';
2111import { UnfollowTargetController } from '../../../modules/user/infrastructure/http/controllers/UnfollowTargetController';
2112
2113export function createControllers(useCases: UseCases): Controllers {
2114 return {
2115 // ... existing controllers ...
2116 followTargetController: new FollowTargetController(
2117 useCases.followTargetUseCase,
2118 ),
2119 unfollowTargetController: new UnfollowTargetController(
2120 useCases.unfollowTargetUseCase,
2121 ),
2122 };
2123}
2124```
2125
2126---
2127
2128#### 10.2 Register Routes in Main App
2129
2130**File:** Main app initialization file (e.g., `src/index.ts` or similar) (MODIFY)
2131
2132**Update route registration:**
2133
2134```typescript
2135import { createUserRoutes } from './modules/user/infrastructure/http/routes/userRoutes';
2136
2137// ... initialization code ...
2138
2139const userRoutes = createUserRoutes(
2140 authMiddleware,
2141 controllers.followTargetController,
2142 controllers.unfollowTargetController,
2143 // ... other user controllers ...
2144);
2145
2146app.use('/api/users', userRoutes);
2147```
2148
2149---
2150
2151## Files Summary
2152
2153### New Files (17):
2154
21551. `src/modules/atproto/infrastructure/lexicons/follow.json` - AT Protocol lexicon definition
21562. `src/modules/user/domain/events/UserFollowedTargetEvent.ts`
21573. `src/modules/user/domain/events/UserUnfollowedTargetEvent.ts`
21584. `src/modules/user/application/ports/IFollowPublisher.ts`
21595. `src/modules/user/application/useCases/commands/FollowTargetUseCase.ts`
21606. `src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts`
21617. `src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts`
21628. `src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts`
21639. `src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts`
216410. `src/modules/user/infrastructure/http/controllers/FollowTargetController.ts`
216511. `src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts`
2166
2167### Modified Files (20):
2168
21691. `src/shared/infrastructure/events/EventConfig.ts` - Add event names
21702. `src/shared/infrastructure/events/EventMapper.ts` - Add serialization/deserialization
21713. `src/modules/user/domain/Follow.ts` - Add publishedRecordId property, markAsPublished(), markForRemoval() methods
21724. `src/modules/user/infrastructure/repositories/schema/follows.sql.ts` - **Add published_record_id column (requires migration)**
21735. `src/modules/cards/tests/test-utils/createTestSchema.ts` - **Update test schema for published_record_id column**
21746. `src/modules/user/domain/repositories/IFollowsRepository.ts` - Add write methods
21757. `src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts` - Implement write methods with publishedRecordId handling
21768. `src/shared/infrastructure/events/BullMQEventPublisher.ts` - Add queue routing
21779. `src/shared/infrastructure/http/factories/ServiceFactory.ts` - Register follow publisher with collectionRepository
217810. `src/shared/infrastructure/http/factories/UseCaseFactory.ts` - Register use cases
217911. `src/shared/infrastructure/config/EnvironmentConfigService.ts` - Add 'network.cosmik.follow' collection
218012. `src/modules/user/infrastructure/http/routes/userRoutes.ts` - Add follow/unfollow routes
218113. `src/types/src/api/requests.ts` - Add follow request types
218214. `src/types/src/api/responses.ts` - Add follow response types and notification types
218315. `src/webapp/api-client/clients/UserClient.ts` - Add follow/unfollow methods
218416. `src/webapp/api-client/ApiClient.ts` - Add delegation methods
218517. `src/shared/infrastructure/http/factories/ControllerFactory.ts` - Register controllers
218618. `src/modules/notifications/domain/Notification.ts` - Add factory methods
218719. `src/modules/notifications/domain/services/NotificationService.ts` - Add notification methods
218820. `src/shared/infrastructure/processes/NotificationWorkerProcess.ts` - Register event handler
218921. Main app initialization file (e.g., `src/index.ts`) - Register routes
2190
2191### Database Migration Required
2192
2193After modifying `follows.sql.ts` to add the `published_record_id` column:
2194
21951. Run `npm run db:generate` to create migration files
21962. Apply migrations to development/production databases