This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

get following feed use case

+2709
+2196
.agent/logs/20260211_following_feed_implementation.md
··· 1 + # Implementation Plan: FollowTargetUseCase & UnfollowTargetUseCase 2 + 3 + ## Overview 4 + 5 + Implement 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 72 + export 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 88 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 89 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 90 + import { DID } from '../value-objects/DID'; 91 + import { FollowTargetType } from '../value-objects/FollowTargetType'; 92 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 93 + import { Result, ok } from '../../../../shared/core/Result'; 94 + 95 + export 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 163 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 164 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 165 + import { DID } from '../value-objects/DID'; 166 + import { FollowTargetType } from '../value-objects/FollowTargetType'; 167 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 168 + import { Result, ok } from '../../../../shared/core/Result'; 169 + 170 + export 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 228 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 229 + 230 + export 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 242 + get publishedRecordId(): PublishedRecordId | undefined { 243 + return this.props.publishedRecordId; 244 + } 245 + ``` 246 + 247 + **Add markAsPublished method (similar to Card):** 248 + 249 + ```typescript 250 + public markAsPublished(publishedRecordId: PublishedRecordId): void { 251 + this.props.publishedRecordId = publishedRecordId; 252 + } 253 + ``` 254 + 255 + **Add markForRemoval method:** 256 + 257 + ```typescript 258 + public 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 278 + public 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(). 295 + The 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** 306 + Add these imports at the top of the file: 307 + 308 + ```typescript 309 + import { UserFollowedTargetEvent } from '../../../modules/user/domain/events/UserFollowedTargetEvent'; 310 + import { UserUnfollowedTargetEvent } from '../../../modules/user/domain/events/UserUnfollowedTargetEvent'; 311 + import { DID } from '../../../modules/user/domain/value-objects/DID'; 312 + import { FollowTargetType } from '../../../modules/user/domain/value-objects/FollowTargetType'; 313 + ``` 314 + 315 + **Step 1.5.1b: Add Serialized Interfaces** 316 + Add these interfaces after existing serialized event interfaces: 317 + 318 + ```typescript 319 + export 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 + 328 + export 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** 338 + Add to the SerializedEventUnion type: 339 + 340 + ```typescript 341 + export type SerializedEventUnion = 342 + | SerializedCardAddedToLibraryEvent 343 + // ... other events ... 344 + | SerializedUserFollowedTargetEvent 345 + | SerializedUserUnfollowedTargetEvent; 346 + ``` 347 + 348 + **Step 1.5.1d: Add toSerialized() Cases** 349 + Add these cases in the `toSerialized()` method: 350 + 351 + ```typescript 352 + if (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 + 365 + if (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** 379 + Add these cases in the `fromSerialized()` method: 380 + 381 + ```typescript 382 + case 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 + 398 + case 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 + */ 432 + save(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 + */ 444 + delete( 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 + */ 458 + findByFollowerAndTarget( 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 474 + import { publishedRecords } from '../../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; 475 + 476 + export 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 509 + import { PublishedRecordId } from '../../../../cards/domain/value-objects/PublishedRecordId'; 510 + 511 + async 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 + 567 + async 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 + 589 + async 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 664 + private follows: Map<string, Follow> = new Map(); 665 + 666 + private getKey(followerId: string, targetId: string, targetType: string): string { 667 + return `${followerId}:${targetId}:${targetType}`; 668 + } 669 + 670 + async 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 + 680 + async 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 + 690 + async 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 712 + import { Result } from '../../../../shared/core/Result'; 713 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 714 + import { Follow } from '../../domain/Follow'; 715 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 716 + import { DID } from '../../domain/value-objects/DID'; 717 + 718 + export 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 740 + rather 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 751 + import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; 752 + import { Follow } from '../../../user/domain/Follow'; 753 + import { Result, ok, err } from '../../../../shared/core/Result'; 754 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 755 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 756 + import { IAgentService } from '../../application/ports/IAgentService'; 757 + import { DID } from '../../domain/DID'; 758 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 759 + import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; 760 + import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; 761 + 762 + export 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 954 + import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; 955 + import { Follow } from '../../../user/domain/Follow'; 956 + import { Result, ok } from '../../../../shared/core/Result'; 957 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 958 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 959 + 960 + export 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 1012 + export interface Services extends SharedServices { 1013 + // ... existing services ... 1014 + followPublisher: IFollowPublisher; 1015 + } 1016 + ``` 1017 + 1018 + **Add to create() method:** 1019 + 1020 + ```typescript 1021 + const 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 + 1029 + return { 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 1044 + getAtProtoCollections() { 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 + 1063 + Update the `getTargetQueues()` method to include routing for the new events: 1064 + 1065 + ```typescript 1066 + private 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 1096 + import { Result, ok, err } from '../../../../../shared/core/Result'; 1097 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 1098 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 1099 + import { AppError } from '../../../../../shared/core/AppError'; 1100 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 1101 + import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; 1102 + import { IUserRepository } from '../../../domain/repositories/IUserRepository'; 1103 + import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; 1104 + import { IFollowPublisher } from '../../ports/IFollowPublisher'; 1105 + import { DID } from '../../../domain/value-objects/DID'; 1106 + import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 1107 + import { Follow } from '../../../domain/Follow'; 1108 + import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; 1109 + 1110 + export interface FollowTargetDTO { 1111 + followerId: string; // DID 1112 + targetId: string; // DID or Collection UUID 1113 + targetType: 'USER' | 'COLLECTION'; 1114 + } 1115 + 1116 + export interface FollowTargetResponseDTO { 1117 + followId: string; 1118 + } 1119 + 1120 + export class ValidationError extends UseCaseError { 1121 + constructor(message: string) { 1122 + super(message); 1123 + } 1124 + } 1125 + 1126 + export 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 1322 + import { Result, ok, err } from '../../../../../shared/core/Result'; 1323 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 1324 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 1325 + import { AppError } from '../../../../../shared/core/AppError'; 1326 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 1327 + import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; 1328 + import { IFollowPublisher } from '../../ports/IFollowPublisher'; 1329 + import { DID } from '../../../domain/value-objects/DID'; 1330 + import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 1331 + 1332 + export interface UnfollowTargetDTO { 1333 + followerId: string; // DID 1334 + targetId: string; // DID or Collection UUID 1335 + targetType: 'USER' | 'COLLECTION'; 1336 + } 1337 + 1338 + export class ValidationError extends UseCaseError { 1339 + constructor(message: string) { 1340 + super(message); 1341 + } 1342 + } 1343 + 1344 + export 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 1459 + export interface UseCases { 1460 + // ... existing use cases ... 1461 + followTargetUseCase: FollowTargetUseCase; 1462 + unfollowTargetUseCase: UnfollowTargetUseCase; 1463 + } 1464 + ``` 1465 + 1466 + **Add to createForWebApp():** 1467 + 1468 + ```typescript 1469 + import { FollowTargetUseCase } from '../../../modules/user/application/useCases/commands/FollowTargetUseCase'; 1470 + import { UnfollowTargetUseCase } from '../../../modules/user/application/useCases/commands/UnfollowTargetUseCase'; 1471 + 1472 + // Inside createForWebApp method: 1473 + followTargetUseCase: new FollowTargetUseCase( 1474 + repositories.followsRepository, 1475 + repositories.userRepository, 1476 + repositories.collectionRepository, 1477 + services.followPublisher, 1478 + services.eventPublisher, 1479 + ), 1480 + unfollowTargetUseCase: 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 1498 + export 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 1517 + export interface FollowNotificationMetadata { 1518 + targetType: 'USER' | 'COLLECTION'; 1519 + targetId?: string; // Collection ID if applicable 1520 + } 1521 + ``` 1522 + 1523 + **Add factory methods:** 1524 + 1525 + ```typescript 1526 + public 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 + 1545 + public 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 1576 + async 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 + 1611 + async 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 1658 + import { IEventHandler } from '../../../../shared/application/events/IEventHandler'; 1659 + import { UserFollowedTargetEvent } from '../../../user/domain/events/UserFollowedTargetEvent'; 1660 + import { Result, ok, err } from '../../../../shared/core/Result'; 1661 + import { NotificationService } from '../../domain/services/NotificationService'; 1662 + import { IUserRepository } from '../../../user/domain/repositories/IUserRepository'; 1663 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 1664 + import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 1665 + import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; 1666 + 1667 + export 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 1780 + import { UserFollowedTargetEventHandler } from '../../../modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler'; 1781 + import { EventNames } from '../events/EventConfig'; 1782 + 1783 + // Inside registerHandlers method: 1784 + const userFollowedTargetHandler = new UserFollowedTargetEventHandler( 1785 + services.notificationService, 1786 + repositories.userRepository, 1787 + repositories.collectionRepository, 1788 + ); 1789 + 1790 + await 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 1807 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 1808 + import { Response } from 'express'; 1809 + import { FollowTargetUseCase } from '../../../application/useCases/commands/FollowTargetUseCase'; 1810 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 1811 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 1812 + 1813 + export 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 1861 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 1862 + import { Response } from 'express'; 1863 + import { UnfollowTargetUseCase } from '../../../application/useCases/commands/UnfollowTargetUseCase'; 1864 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 1865 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 1866 + 1867 + export 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 1917 + import { Router } from 'express'; 1918 + import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 1919 + import { FollowTargetController } from '../controllers/FollowTargetController'; 1920 + import { UnfollowTargetController } from '../controllers/UnfollowTargetController'; 1921 + 1922 + export 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 1963 + export interface FollowTargetRequest { 1964 + targetId: string; // DID or Collection UUID 1965 + targetType: 'USER' | 'COLLECTION'; 1966 + } 1967 + 1968 + export 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 1983 + export interface FollowTargetResponse { 1984 + followId: string; 1985 + } 1986 + 1987 + // Optional: Response types for getting follows 1988 + export interface GetFollowsParams extends PaginationParams { 1989 + targetType?: 'USER' | 'COLLECTION'; 1990 + } 1991 + 1992 + export interface FollowDTO { 1993 + followId: string; 1994 + followerId: string; 1995 + targetId: string; 1996 + targetType: 'USER' | 'COLLECTION'; 1997 + createdAt: string; 1998 + } 1999 + 2000 + export 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 2017 + import { FollowTargetRequest, FollowTargetResponse } from '@semble/types'; 2018 + 2019 + export 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 2063 + export 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 2100 + export interface Controllers { 2101 + // ... existing controllers ... 2102 + followTargetController: FollowTargetController; 2103 + unfollowTargetController: UnfollowTargetController; 2104 + } 2105 + ``` 2106 + 2107 + **Add controller instantiation:** 2108 + 2109 + ```typescript 2110 + import { FollowTargetController } from '../../../modules/user/infrastructure/http/controllers/FollowTargetController'; 2111 + import { UnfollowTargetController } from '../../../modules/user/infrastructure/http/controllers/UnfollowTargetController'; 2112 + 2113 + export 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 2135 + import { createUserRoutes } from './modules/user/infrastructure/http/routes/userRoutes'; 2136 + 2137 + // ... initialization code ... 2138 + 2139 + const userRoutes = createUserRoutes( 2140 + authMiddleware, 2141 + controllers.followTargetController, 2142 + controllers.unfollowTargetController, 2143 + // ... other user controllers ... 2144 + ); 2145 + 2146 + app.use('/api/users', userRoutes); 2147 + ``` 2148 + 2149 + --- 2150 + 2151 + ## Files Summary 2152 + 2153 + ### New Files (17): 2154 + 2155 + 1. `src/modules/atproto/infrastructure/lexicons/follow.json` - AT Protocol lexicon definition 2156 + 2. `src/modules/user/domain/events/UserFollowedTargetEvent.ts` 2157 + 3. `src/modules/user/domain/events/UserUnfollowedTargetEvent.ts` 2158 + 4. `src/modules/user/application/ports/IFollowPublisher.ts` 2159 + 5. `src/modules/user/application/useCases/commands/FollowTargetUseCase.ts` 2160 + 6. `src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts` 2161 + 7. `src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts` 2162 + 8. `src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts` 2163 + 9. `src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts` 2164 + 10. `src/modules/user/infrastructure/http/controllers/FollowTargetController.ts` 2165 + 11. `src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts` 2166 + 2167 + ### Modified Files (20): 2168 + 2169 + 1. `src/shared/infrastructure/events/EventConfig.ts` - Add event names 2170 + 2. `src/shared/infrastructure/events/EventMapper.ts` - Add serialization/deserialization 2171 + 3. `src/modules/user/domain/Follow.ts` - Add publishedRecordId property, markAsPublished(), markForRemoval() methods 2172 + 4. `src/modules/user/infrastructure/repositories/schema/follows.sql.ts` - **Add published_record_id column (requires migration)** 2173 + 5. `src/modules/cards/tests/test-utils/createTestSchema.ts` - **Update test schema for published_record_id column** 2174 + 6. `src/modules/user/domain/repositories/IFollowsRepository.ts` - Add write methods 2175 + 7. `src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts` - Implement write methods with publishedRecordId handling 2176 + 8. `src/shared/infrastructure/events/BullMQEventPublisher.ts` - Add queue routing 2177 + 9. `src/shared/infrastructure/http/factories/ServiceFactory.ts` - Register follow publisher with collectionRepository 2178 + 10. `src/shared/infrastructure/http/factories/UseCaseFactory.ts` - Register use cases 2179 + 11. `src/shared/infrastructure/config/EnvironmentConfigService.ts` - Add 'network.cosmik.follow' collection 2180 + 12. `src/modules/user/infrastructure/http/routes/userRoutes.ts` - Add follow/unfollow routes 2181 + 13. `src/types/src/api/requests.ts` - Add follow request types 2182 + 14. `src/types/src/api/responses.ts` - Add follow response types and notification types 2183 + 15. `src/webapp/api-client/clients/UserClient.ts` - Add follow/unfollow methods 2184 + 16. `src/webapp/api-client/ApiClient.ts` - Add delegation methods 2185 + 17. `src/shared/infrastructure/http/factories/ControllerFactory.ts` - Register controllers 2186 + 18. `src/modules/notifications/domain/Notification.ts` - Add factory methods 2187 + 19. `src/modules/notifications/domain/services/NotificationService.ts` - Add notification methods 2188 + 20. `src/shared/infrastructure/processes/NotificationWorkerProcess.ts` - Register event handler 2189 + 21. Main app initialization file (e.g., `src/index.ts`) - Register routes 2190 + 2191 + ### Database Migration Required 2192 + 2193 + After modifying `follows.sql.ts` to add the `published_record_id` column: 2194 + 2195 + 1. Run `npm run db:generate` to create migration files 2196 + 2. Apply migrations to development/production databases
+403
src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { UseCase } from '../../../../../shared/core/UseCase'; 3 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4 + import { AppError } from '../../../../../shared/core/AppError'; 5 + import { IFeedRepository } from '../../../domain/IFeedRepository'; 6 + import { ActivityId } from '../../../domain/value-objects/ActivityId'; 7 + import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 8 + import { 9 + ICardQueryRepository, 10 + UrlCardView, 11 + } from '../../../../cards/domain/ICardQueryRepository'; 12 + import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; 13 + import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 14 + import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 15 + import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 16 + import { CollectionAccessType } from '../../../../cards/domain/Collection'; 17 + 18 + export interface GetFollowingFeedQuery { 19 + callingUserId: string; 20 + page?: number; 21 + limit?: number; 22 + beforeActivityId?: string; // For cursor-based pagination 23 + urlType?: string; // Filter by URL type 24 + source?: ActivitySource; // Filter by activity source 25 + } 26 + 27 + // Use the shared API type directly 28 + export type GetFollowingFeedResult = GetGlobalFeedResponse; 29 + 30 + export class ValidationError extends UseCaseError { 31 + constructor(message: string) { 32 + super(message); 33 + } 34 + } 35 + 36 + export class GetFollowingFeedUseCase 37 + implements 38 + UseCase< 39 + GetFollowingFeedQuery, 40 + Result<GetFollowingFeedResult, ValidationError | AppError.UnexpectedError> 41 + > 42 + { 43 + constructor( 44 + private feedRepository: IFeedRepository, 45 + private profileService: IProfileService, 46 + private cardQueryRepository: ICardQueryRepository, 47 + private collectionRepository: ICollectionRepository, 48 + ) {} 49 + 50 + async execute( 51 + query: GetFollowingFeedQuery, 52 + ): Promise< 53 + Result<GetFollowingFeedResult, ValidationError | AppError.UnexpectedError> 54 + > { 55 + try { 56 + // Set defaults and validate 57 + const page = query.page || 1; 58 + const limit = Math.min(query.limit || 20, 100); // Cap at 100 59 + 60 + let beforeActivityId: ActivityId | undefined; 61 + if (query.beforeActivityId) { 62 + const activityIdResult = ActivityId.createFromString( 63 + query.beforeActivityId, 64 + ); 65 + if (activityIdResult.isErr()) { 66 + return err( 67 + new ValidationError( 68 + `Invalid beforeActivityId: ${activityIdResult.error.message}`, 69 + ), 70 + ); 71 + } 72 + beforeActivityId = activityIdResult.value; 73 + } 74 + 75 + // Parse urlType if provided 76 + let urlType: UrlType | undefined; 77 + if (query.urlType) { 78 + urlType = query.urlType as UrlType; 79 + } 80 + 81 + // Fetch activities from repository for the user's following feed 82 + const feedResult = await this.feedRepository.getFollowingFeed( 83 + query.callingUserId, 84 + { 85 + page, 86 + limit, 87 + beforeActivityId, 88 + urlType, 89 + source: query.source, 90 + }, 91 + ); 92 + 93 + if (feedResult.isErr()) { 94 + return err(AppError.UnexpectedError.create(feedResult.error)); 95 + } 96 + 97 + const feed = feedResult.value; 98 + 99 + // Get unique actor IDs for profile enrichment 100 + const actorIds = [ 101 + ...new Set(feed.activities.map((activity) => activity.actorId.value)), 102 + ]; 103 + 104 + // Fetch profiles for all actors 105 + const actorProfiles = new Map< 106 + string, 107 + { 108 + id: string; 109 + name: string; 110 + handle: string; 111 + avatarUrl?: string; 112 + description?: string; 113 + } 114 + >(); 115 + const profileResults = await Promise.all( 116 + actorIds.map((actorId) => this.profileService.getProfile(actorId)), 117 + ); 118 + 119 + profileResults.forEach((profileResult, idx) => { 120 + const actorId = actorIds[idx]; 121 + if (!actorId) { 122 + return; 123 + } 124 + if (profileResult.isOk()) { 125 + const profile = profileResult.value; 126 + actorProfiles.set(actorId, { 127 + id: profile.id, 128 + name: profile.name, 129 + handle: profile.handle, 130 + avatarUrl: profile.avatarUrl, 131 + description: profile.bio, 132 + }); 133 + } else { 134 + // If profile fetch fails, create a fallback 135 + actorProfiles.set(actorId, { 136 + id: actorId, 137 + name: 'Unknown User', 138 + handle: actorId, 139 + }); 140 + } 141 + }); 142 + 143 + // Get unique card IDs for hydration 144 + const cardIds = [ 145 + ...new Set( 146 + feed.activities 147 + .filter((activity) => activity.cardCollected) 148 + .map((activity) => activity.metadata.cardId), 149 + ), 150 + ]; 151 + 152 + // Hydrate card data and fetch card authors 153 + const cardDataMap = new Map<string, UrlCardView>(); 154 + const cardViews = await Promise.all( 155 + cardIds.map((cardId) => 156 + this.cardQueryRepository.getUrlCardView(cardId, query.callingUserId), 157 + ), 158 + ); 159 + cardIds.forEach((cardId, idx) => { 160 + const cardView = cardViews[idx]; 161 + if (cardView) { 162 + cardDataMap.set(cardId, cardView); 163 + } 164 + }); 165 + 166 + // Get unique card author IDs 167 + const cardAuthorIds = [ 168 + ...new Set( 169 + Array.from(cardDataMap.values()).map((card) => card.authorId), 170 + ), 171 + ]; 172 + 173 + // Fetch card author profiles 174 + const cardAuthorProfiles = new Map< 175 + string, 176 + { 177 + id: string; 178 + name: string; 179 + handle: string; 180 + avatarUrl?: string; 181 + description?: string; 182 + } 183 + >(); 184 + const cardAuthorResults = await Promise.all( 185 + cardAuthorIds.map((authorId) => 186 + this.profileService.getProfile(authorId, query.callingUserId), 187 + ), 188 + ); 189 + 190 + cardAuthorResults.forEach((profileResult, idx) => { 191 + const authorId = cardAuthorIds[idx]; 192 + if (!authorId) { 193 + return; 194 + } 195 + if (profileResult.isOk()) { 196 + const profile = profileResult.value; 197 + cardAuthorProfiles.set(authorId, { 198 + id: profile.id, 199 + name: profile.name, 200 + handle: profile.handle, 201 + avatarUrl: profile.avatarUrl, 202 + description: profile.bio, 203 + }); 204 + } 205 + }); 206 + 207 + // Get collection data for activities that have collections 208 + const collectionIds = [ 209 + ...new Set( 210 + feed.activities 211 + .filter( 212 + (activity) => 213 + activity.cardCollected && activity.metadata.collectionIds, 214 + ) 215 + .flatMap((activity) => activity.metadata.collectionIds || []), 216 + ), 217 + ]; 218 + 219 + const collectionDataMap = new Map< 220 + string, 221 + { 222 + id: string; 223 + uri?: string; 224 + name: string; 225 + description?: string; 226 + accessType: CollectionAccessType; 227 + author: { 228 + id: string; 229 + name: string; 230 + handle: string; 231 + avatarUrl?: string; 232 + description?: string; 233 + }; 234 + cardCount: number; 235 + createdAt: string; 236 + updatedAt: string; 237 + cardIds: Set<string>; // Track which cards are in this collection 238 + } 239 + >(); 240 + // Fetch all collections in parallel using Promise.all 241 + const collectionResults = await Promise.all( 242 + collectionIds.map(async (collectionId) => { 243 + const collectionIdResult = 244 + CollectionId.createFromString(collectionId); 245 + if (collectionIdResult.isErr()) { 246 + return null; // Skip invalid collection IDs 247 + } 248 + const collectionResult = await this.collectionRepository.findById( 249 + collectionIdResult.value, 250 + ); 251 + if (collectionResult.isErr() || !collectionResult.value) { 252 + return null; 253 + } 254 + 255 + const collection = collectionResult.value; 256 + 257 + // Get author profile 258 + const authorProfileResult = await this.profileService.getProfile( 259 + collection.authorId.value, 260 + query.callingUserId, 261 + ); 262 + if (authorProfileResult.isErr()) { 263 + return null; 264 + } 265 + 266 + const authorProfile = authorProfileResult.value; 267 + const uri = collection.publishedRecordId?.uri; 268 + 269 + // Get the card IDs in this collection 270 + const cardIds = new Set( 271 + collection.cardIds.map((cardId) => cardId.getStringValue()), 272 + ); 273 + 274 + return { 275 + id: collection.collectionId.getStringValue(), 276 + uri, 277 + name: collection.name.toString(), 278 + description: collection.description?.toString(), 279 + accessType: collection.accessType, 280 + author: { 281 + id: authorProfile.id, 282 + name: authorProfile.name, 283 + handle: authorProfile.handle, 284 + avatarUrl: authorProfile.avatarUrl, 285 + description: authorProfile.bio, 286 + }, 287 + cardCount: collection.cardCount, 288 + createdAt: collection.createdAt.toISOString(), 289 + updatedAt: collection.updatedAt.toISOString(), 290 + cardIds, 291 + collectionId, 292 + }; 293 + }), 294 + ); 295 + 296 + collectionResults.forEach((result) => { 297 + if (result) { 298 + collectionDataMap.set(result.collectionId, { 299 + id: result.id, 300 + uri: result.uri, 301 + name: result.name, 302 + description: result.description, 303 + accessType: result.accessType, 304 + author: result.author, 305 + cardCount: result.cardCount, 306 + createdAt: result.createdAt, 307 + updatedAt: result.updatedAt, 308 + cardIds: result.cardIds, 309 + }); 310 + } 311 + }); 312 + 313 + // Transform activities to FeedItem 314 + const feedItems: FeedItem[] = []; 315 + for (const activity of feed.activities) { 316 + if (!activity.cardCollected) { 317 + continue; // Skip non-card-collected activities 318 + } 319 + 320 + const actor = actorProfiles.get(activity.actorId.value); 321 + const cardView = cardDataMap.get(activity.metadata.cardId); 322 + 323 + if (!actor || !cardView) { 324 + continue; // Skip if we can't hydrate required data 325 + } 326 + 327 + // Get card author 328 + const cardAuthor = cardAuthorProfiles.get(cardView.authorId); 329 + if (!cardAuthor) { 330 + continue; // Skip if we can't get card author 331 + } 332 + 333 + // Transform UrlCardView to UrlCardDTO 334 + const cardDTO = { 335 + id: cardView.id, 336 + type: 'URL' as const, 337 + url: cardView.url, 338 + uri: cardView.uri, 339 + cardContent: { 340 + url: cardView.cardContent.url, 341 + title: cardView.cardContent.title, 342 + description: cardView.cardContent.description, 343 + author: cardView.cardContent.author, 344 + publishedDate: cardView.cardContent.publishedDate?.toISOString(), 345 + siteName: cardView.cardContent.siteName, 346 + imageUrl: cardView.cardContent.imageUrl, 347 + type: cardView.cardContent.type, 348 + retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), 349 + doi: cardView.cardContent.doi, 350 + isbn: cardView.cardContent.isbn, 351 + }, 352 + libraryCount: cardView.libraryCount, 353 + urlLibraryCount: cardView.urlLibraryCount, 354 + urlInLibrary: cardView.urlInLibrary, 355 + createdAt: cardView.createdAt.toISOString(), 356 + updatedAt: cardView.updatedAt.toISOString(), 357 + author: cardAuthor, 358 + note: cardView.note, 359 + }; 360 + 361 + const collections = (activity.metadata.collectionIds || []) 362 + .map((collectionId) => collectionDataMap.get(collectionId)) 363 + .filter((collection) => !!collection) 364 + .filter((collection) => 365 + collection.cardIds.has(activity.metadata.cardId), 366 + ) 367 + .map((collection) => ({ 368 + id: collection.id, 369 + uri: collection.uri, 370 + name: collection.name, 371 + description: collection.description, 372 + accessType: collection.accessType, 373 + author: collection.author, 374 + cardCount: collection.cardCount, 375 + createdAt: collection.createdAt, 376 + updatedAt: collection.updatedAt, 377 + })); 378 + 379 + feedItems.push({ 380 + id: activity.activityId.getStringValue(), 381 + user: actor, 382 + card: cardDTO, 383 + createdAt: activity.createdAt, 384 + collections, 385 + }); 386 + } 387 + 388 + return ok({ 389 + activities: feedItems, 390 + pagination: { 391 + currentPage: page, 392 + totalPages: Math.ceil(feed.totalCount / limit), 393 + totalCount: feed.totalCount, 394 + hasMore: feed.hasMore, 395 + limit, 396 + nextCursor: feed.nextCursor?.getStringValue(), 397 + }, 398 + }); 399 + } catch (error) { 400 + return err(AppError.UnexpectedError.create(error)); 401 + } 402 + } 403 + }
+56
src/modules/feeds/infrastructure/http/controllers/GetFollowingFeedController.ts
··· 1 + import { Response } from 'express'; 2 + import { z } from 'zod'; 3 + import { GetFollowingFeedUseCase } from '../../../application/useCases/queries/GetFollowingFeedUseCase'; 4 + import { Controller } from 'src/shared/infrastructure/http/Controller'; 5 + import { AuthenticatedRequest } from 'src/shared/infrastructure/http/middleware/AuthMiddleware'; 6 + import { GetGlobalFeedResponse, ActivitySource } from '@semble/types'; 7 + 8 + // Zod schema for request validation 9 + const querySchema = z.object({ 10 + page: z.coerce.number().int().positive().optional(), 11 + limit: z.coerce.number().int().positive().max(100).optional(), 12 + beforeActivityId: z.string().optional(), 13 + urlType: z.string().optional(), 14 + source: z.nativeEnum(ActivitySource).optional(), 15 + }); 16 + 17 + export class GetFollowingFeedController extends Controller { 18 + constructor(private getFollowingFeedUseCase: GetFollowingFeedUseCase) { 19 + super(); 20 + } 21 + 22 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 23 + try { 24 + // Validate request with Zod 25 + const validation = querySchema.safeParse(req.query); 26 + if (!validation.success) { 27 + return this.badRequest(res, JSON.stringify(validation.error.format())); 28 + } 29 + 30 + const params = validation.data; 31 + const callerDid = req.did; 32 + 33 + // Following feed requires authentication 34 + if (!callerDid) { 35 + return this.unauthorized(res, 'Authentication required'); 36 + } 37 + 38 + const result = await this.getFollowingFeedUseCase.execute({ 39 + callingUserId: callerDid, 40 + page: params.page || 1, 41 + limit: params.limit || 20, 42 + beforeActivityId: params.beforeActivityId, 43 + urlType: params.urlType, 44 + source: params.source, 45 + }); 46 + 47 + if (result.isErr()) { 48 + return this.fail(res, result.error.message); 49 + } 50 + 51 + return this.ok<GetGlobalFeedResponse>(res, result.value); 52 + } catch (error) { 53 + return this.fail(res, 'An unexpected error occurred'); 54 + } 55 + } 56 + }
+7
src/modules/feeds/infrastructure/http/routes/feedRoutes.ts
··· 1 1 import { Router } from 'express'; 2 2 import { GetGlobalFeedController } from '../controllers/GetGlobalFeedController'; 3 3 import { GetGemActivityFeedController } from '../controllers/GetGemActivityFeedController'; 4 + import { GetFollowingFeedController } from '../controllers/GetFollowingFeedController'; 4 5 import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 6 6 7 export function createFeedRoutes( 7 8 authMiddleware: AuthMiddleware, 8 9 getGlobalFeedController: GetGlobalFeedController, 9 10 getGemActivityFeedController: GetGemActivityFeedController, 11 + getFollowingFeedController: GetFollowingFeedController, 10 12 ): Router { 11 13 const router = Router(); 12 14 ··· 21 23 // GET /api/feeds/gem - Get gem activity feed (filtered for collections with 💎 and 2025) 22 24 router.get('/gem', (req, res) => 23 25 getGemActivityFeedController.execute(req, res), 26 + ); 27 + 28 + // GET /api/feeds/following - Get following feed (personalized feed from followed users) 29 + router.get('/following', (req, res) => 30 + getFollowingFeedController.execute(req, res), 24 31 ); 25 32 26 33 return router;
+1
src/shared/infrastructure/http/app.ts
··· 124 124 services.authMiddleware, 125 125 controllers.getGlobalFeedController, 126 126 controllers.getGemActivityFeedController, 127 + controllers.getFollowingFeedController, 127 128 ); 128 129 129 130 const searchRouter = createSearchRoutes(
+5
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 20 20 import { GetMyCollectionsController } from '../../../../modules/cards/infrastructure/http/controllers/GetMyCollectionsController'; 21 21 import { GetGlobalFeedController } from '../../../../modules/feeds/infrastructure/http/controllers/GetGlobalFeedController'; 22 22 import { GetGemActivityFeedController } from '../../../../modules/feeds/infrastructure/http/controllers/GetGemActivityFeedController'; 23 + import { GetFollowingFeedController } from '../../../../modules/feeds/infrastructure/http/controllers/GetFollowingFeedController'; 23 24 import { GetSimilarUrlsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/GetSimilarUrlsForUrlController'; 24 25 import { SemanticSearchUrlsController } from '../../../../modules/search/infrastructure/http/controllers/SemanticSearchUrlsController'; 25 26 import { SearchBskyPostsForUrlController } from '../../../../modules/search/infrastructure/http/controllers/SearchBskyPostsForUrlController'; ··· 88 89 // Feed controllers 89 90 getGlobalFeedController: GetGlobalFeedController; 90 91 getGemActivityFeedController: GetGemActivityFeedController; 92 + getFollowingFeedController: GetFollowingFeedController; 91 93 // Search controllers 92 94 getSimilarUrlsForUrlController: GetSimilarUrlsForUrlController; 93 95 semanticSearchUrlsController: SemanticSearchUrlsController; ··· 228 230 ), 229 231 getGemActivityFeedController: new GetGemActivityFeedController( 230 232 useCases.getGemActivityFeedUseCase, 233 + ), 234 + getFollowingFeedController: new GetFollowingFeedController( 235 + useCases.getFollowingFeedUseCase, 231 236 ), 232 237 // Search controllers 233 238 getSimilarUrlsForUrlController: new GetSimilarUrlsForUrlController(
+8
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 24 24 import { GenerateExtensionTokensUseCase } from 'src/modules/user/application/use-cases/GenerateExtensionTokensUseCase'; 25 25 import { GetGlobalFeedUseCase } from '../../../../modules/feeds/application/useCases/queries/GetGlobalFeedUseCase'; 26 26 import { GetGemActivityFeedUseCase } from '../../../../modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase'; 27 + import { GetFollowingFeedUseCase } from '../../../../modules/feeds/application/useCases/queries/GetFollowingFeedUseCase'; 27 28 import { AddActivityToFeedUseCase } from '../../../../modules/feeds/application/useCases/commands/AddActivityToFeedUseCase'; 28 29 import { GetCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionsUseCase'; 29 30 import { SearchCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/SearchCollectionsUseCase'; ··· 114 115 // Feed use cases 115 116 getGlobalFeedUseCase: GetGlobalFeedUseCase; 116 117 getGemActivityFeedUseCase: GetGemActivityFeedUseCase; 118 + getFollowingFeedUseCase: GetFollowingFeedUseCase; 117 119 addActivityToFeedUseCase: AddActivityToFeedUseCase; 118 120 // Search use cases 119 121 getSimilarUrlsForUrlUseCase: GetSimilarUrlsForUrlUseCase; ··· 311 313 repositories.cardQueryRepository, 312 314 repositories.collectionRepository, 313 315 repositories.collectionQueryRepository, 316 + ), 317 + getFollowingFeedUseCase: new GetFollowingFeedUseCase( 318 + repositories.feedRepository, 319 + services.profileService, 320 + repositories.cardQueryRepository, 321 + repositories.collectionRepository, 314 322 ), 315 323 addActivityToFeedUseCase: new AddActivityToFeedUseCase( 316 324 services.feedService,
+6
src/types/src/api/requests.ts
··· 130 130 source?: ActivitySource; // Filter by activity source 131 131 } 132 132 133 + export interface GetFollowingFeedParams extends PaginationParams { 134 + beforeActivityId?: string; // For cursor-based pagination 135 + urlType?: UrlType; // Filter by URL type 136 + source?: ActivitySource; // Filter by activity source 137 + } 138 + 133 139 export interface LoginWithAppPasswordRequest { 134 140 identifier: string; 135 141 appPassword: string;
+7
src/webapp/api-client/ApiClient.ts
··· 28 28 GetCollectionPageByAtUriParams, 29 29 GetMyCollectionsParams, 30 30 GetGlobalFeedParams, 31 + GetFollowingFeedParams, 31 32 // Response types 32 33 AddUrlToLibraryResponse, 33 34 AddCardToLibraryResponse, ··· 352 353 params?: GetGemActivityFeedParams, 353 354 ): Promise<GetGlobalFeedResponse> { 354 355 return this.feedClient.getGemsActivityFeed(params); 356 + } 357 + 358 + async getFollowingFeed( 359 + params?: GetFollowingFeedParams, 360 + ): Promise<GetGlobalFeedResponse> { 361 + return this.feedClient.getFollowingFeed(params); 355 362 } 356 363 357 364 // Notification operations - delegate to NotificationClient
+20
src/webapp/api-client/clients/FeedClient.ts
··· 2 2 import { 3 3 GetGemActivityFeedParams, 4 4 GetGlobalFeedParams, 5 + GetFollowingFeedParams, 5 6 GetGlobalFeedResponse, 6 7 } from '@semble/types'; 7 8 ··· 38 39 const endpoint = queryString 39 40 ? `/api/feeds/gem?${queryString}` 40 41 : '/api/feeds/gem'; 42 + 43 + return this.request<GetGlobalFeedResponse>('GET', endpoint); 44 + } 45 + 46 + async getFollowingFeed( 47 + params?: GetFollowingFeedParams, 48 + ): Promise<GetGlobalFeedResponse> { 49 + const searchParams = new URLSearchParams(); 50 + if (params?.page) searchParams.set('page', params.page.toString()); 51 + if (params?.limit) searchParams.set('limit', params.limit.toString()); 52 + if (params?.beforeActivityId) 53 + searchParams.set('beforeActivityId', params.beforeActivityId); 54 + if (params?.urlType) searchParams.set('urlType', params.urlType); 55 + if (params?.source) searchParams.set('source', params.source); 56 + 57 + const queryString = searchParams.toString(); 58 + const endpoint = queryString 59 + ? `/api/feeds/following?${queryString}` 60 + : '/api/feeds/following'; 41 61 42 62 return this.request<GetGlobalFeedResponse>('GET', endpoint); 43 63 }