This repository has no description
0

Configure Feed

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

follow / unfollow feature implementation + notifications

+3212 -2
+29
src/modules/atproto/infrastructure/lexicon/lexicons.ts
··· 316 316 }, 317 317 }, 318 318 }, 319 + NetworkCosmikFollow: { 320 + lexicon: 1, 321 + id: 'network.cosmik.follow', 322 + description: 'A record representing a follow relationship.', 323 + defs: { 324 + main: { 325 + type: 'record', 326 + description: 'A record representing a follow of a user or collection.', 327 + key: 'tid', 328 + record: { 329 + type: 'object', 330 + required: ['subject', 'createdAt'], 331 + properties: { 332 + subject: { 333 + type: 'string', 334 + description: 335 + 'DID of the user being followed, or AT URI of the collection being followed', 336 + }, 337 + createdAt: { 338 + type: 'string', 339 + format: 'datetime', 340 + description: 'Timestamp when this follow was created.', 341 + }, 342 + }, 343 + }, 344 + }, 345 + }, 346 + }, 319 347 ComAtprotoRepoStrongRef: { 320 348 lexicon: 1, 321 349 id: 'com.atproto.repo.strongRef', ··· 963 991 NetworkCosmikCollectionLink: 'network.cosmik.collectionLink', 964 992 NetworkCosmikCollectionLinkRemoval: 'network.cosmik.collectionLinkRemoval', 965 993 NetworkCosmikDefs: 'network.cosmik.defs', 994 + NetworkCosmikFollow: 'network.cosmik.follow', 966 995 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 967 996 AtMarginAnnotation: 'at.margin.annotation', 968 997 AtMarginBookmark: 'at.margin.bookmark',
+34
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/follow.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon'; 5 + import { CID } from 'multiformats/cid'; 6 + import { validate as _validate } from '../../../lexicons'; 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../util'; 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate; 15 + const id = 'network.cosmik.follow'; 16 + 17 + export interface Record { 18 + $type: 'network.cosmik.follow'; 19 + /** DID of the user being followed, or AT URI of the collection being followed */ 20 + subject: string; 21 + /** Timestamp when this follow was created. */ 22 + createdAt: string; 23 + [k: string]: unknown; 24 + } 25 + 26 + const hashRecord = 'main'; 27 + 28 + export function isRecord<V>(v: V) { 29 + return is$typed(v, id, hashRecord); 30 + } 31 + 32 + export function validateRecord<V>(v: V) { 33 + return validate<Record & V>(v, id, hashRecord, true); 34 + }
+27
src/modules/atproto/infrastructure/lexicons/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.cosmik.follow", 4 + "description": "A record representing a follow relationship.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A record representing a follow of a user or collection.", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["subject", "createdAt"], 13 + "properties": { 14 + "subject": { 15 + "type": "string", 16 + "description": "DID of the user being followed, or AT URI of the collection being followed" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Timestamp when this follow was created." 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+178
src/modules/atproto/infrastructure/publishers/ATProtoFollowPublisher.ts
··· 1 + import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; 2 + import { Follow } from '../../../user/domain/Follow'; 3 + import { Result, ok, err } from '../../../../shared/core/Result'; 4 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 5 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 6 + import { IAgentService } from '../../application/IAgentService'; 7 + import { DID } from '../../domain/DID'; 8 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 9 + import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; 10 + import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; 11 + 12 + export class ATProtoFollowPublisher implements IFollowPublisher { 13 + constructor( 14 + private readonly agentService: IAgentService, 15 + private readonly followCollection: string, // 'network.cosmik.follow' 16 + private readonly collectionRepository: ICollectionRepository, 17 + ) {} 18 + 19 + async publishFollow( 20 + follow: Follow, 21 + ): Promise<Result<PublishedRecordId, UseCaseError>> { 22 + try { 23 + const followerDid = DID.create(follow.followerId.value); 24 + if (followerDid.isErr()) { 25 + return err( 26 + new Error(`Invalid follower DID: ${followerDid.error.message}`), 27 + ); 28 + } 29 + 30 + const agentResult = await this.agentService.getAuthenticatedAgent( 31 + followerDid.value, 32 + ); 33 + 34 + if (agentResult.isErr()) { 35 + // Propagate authentication errors as-is 36 + if (agentResult.error instanceof AuthenticationError) { 37 + return err(agentResult.error); 38 + } 39 + return err( 40 + new Error( 41 + `Failed to get authenticated agent: ${agentResult.error.message}`, 42 + ), 43 + ); 44 + } 45 + 46 + const agent = agentResult.value; 47 + 48 + if (!agent) { 49 + return err(new Error('No authenticated session found for follower')); 50 + } 51 + 52 + // Determine the subject based on target type 53 + let subject: string; 54 + if (follow.targetType.value === 'USER') { 55 + // For user follows, subject is the DID 56 + subject = follow.targetId; 57 + } else { 58 + // For collection follows, subject is the AT URI of the published collection 59 + const collectionIdResult = CollectionId.createFromString( 60 + follow.targetId, 61 + ); 62 + if (collectionIdResult.isErr()) { 63 + return err( 64 + new Error( 65 + `Invalid collection ID: ${collectionIdResult.error.message}`, 66 + ), 67 + ); 68 + } 69 + 70 + const collectionResult = await this.collectionRepository.findById( 71 + collectionIdResult.value, 72 + ); 73 + if (collectionResult.isErr()) { 74 + return err( 75 + new Error( 76 + `Failed to find collection: ${collectionResult.error.message}`, 77 + ), 78 + ); 79 + } 80 + 81 + const collection = collectionResult.value; 82 + if (!collection) { 83 + return err(new Error('Collection not found')); 84 + } 85 + 86 + if (!collection.publishedRecordId) { 87 + return err( 88 + new Error('Collection must be published before it can be followed'), 89 + ); 90 + } 91 + 92 + // Use the AT URI of the published collection 93 + subject = collection.publishedRecordId.uri; 94 + } 95 + 96 + const record = { 97 + $type: this.followCollection, 98 + subject: subject, 99 + createdAt: follow.createdAt.toISOString(), 100 + }; 101 + 102 + const createResult = await agent.com.atproto.repo.createRecord({ 103 + repo: follow.followerId.value, 104 + collection: this.followCollection, 105 + record, 106 + }); 107 + 108 + const publishedRecordId = PublishedRecordId.create({ 109 + uri: createResult.data.uri, 110 + cid: createResult.data.cid, 111 + }); 112 + 113 + return ok(publishedRecordId); 114 + } catch (error) { 115 + return err( 116 + new Error( 117 + `Failed to publish follow: ${error instanceof Error ? error.message : 'Unknown error'}`, 118 + ), 119 + ); 120 + } 121 + } 122 + 123 + async unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>> { 124 + try { 125 + if (!follow.publishedRecordId) { 126 + // Already unpublished or never published 127 + return ok(undefined); 128 + } 129 + 130 + const followerDid = DID.create(follow.followerId.value); 131 + if (followerDid.isErr()) { 132 + return err( 133 + new Error(`Invalid follower DID: ${followerDid.error.message}`), 134 + ); 135 + } 136 + 137 + const agentResult = await this.agentService.getAuthenticatedAgent( 138 + followerDid.value, 139 + ); 140 + 141 + if (agentResult.isErr()) { 142 + // Propagate authentication errors as-is 143 + if (agentResult.error instanceof AuthenticationError) { 144 + return err(agentResult.error); 145 + } 146 + return err( 147 + new Error( 148 + `Failed to get authenticated agent: ${agentResult.error.message}`, 149 + ), 150 + ); 151 + } 152 + 153 + const agent = agentResult.value; 154 + 155 + if (!agent) { 156 + return err(new Error('No authenticated session found for follower')); 157 + } 158 + 159 + // Extract rkey from AT URI (format: at://did/collection/rkey) 160 + const uriParts = follow.publishedRecordId.uri.split('/'); 161 + const rkey = uriParts[uriParts.length - 1]!; 162 + 163 + await agent.com.atproto.repo.deleteRecord({ 164 + repo: follow.followerId.value, 165 + collection: this.followCollection, 166 + rkey, 167 + }); 168 + 169 + return ok(undefined); 170 + } catch (error) { 171 + return err( 172 + new Error( 173 + `Failed to unpublish follow: ${error instanceof Error ? error.message : 'Unknown error'}`, 174 + ), 175 + ); 176 + } 177 + } 178 + }
+47
src/modules/atproto/infrastructure/publishers/FakeFollowPublisher.ts
··· 1 + import { IFollowPublisher } from '../../../user/application/ports/IFollowPublisher'; 2 + import { Follow } from '../../../user/domain/Follow'; 3 + import { Result, ok } from '../../../../shared/core/Result'; 4 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 5 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 6 + 7 + export class FakeFollowPublisher implements IFollowPublisher { 8 + private publishedFollows: Map<string, PublishedRecordId> = new Map(); 9 + 10 + async publishFollow( 11 + follow: Follow, 12 + ): Promise<Result<PublishedRecordId, UseCaseError>> { 13 + const key = `${follow.followerId.value}:${follow.targetId}`; 14 + const fakeUri = `at://${follow.followerId.value}/network.cosmik.follow/${Date.now()}`; 15 + const fakeCid = `fake-cid-${Date.now()}`; 16 + 17 + const recordId = PublishedRecordId.create({ 18 + uri: fakeUri, 19 + cid: fakeCid, 20 + }); 21 + 22 + this.publishedFollows.set(key, recordId); 23 + 24 + return ok(recordId); 25 + } 26 + 27 + async unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>> { 28 + if (!follow.publishedRecordId) { 29 + return ok(undefined); 30 + } 31 + 32 + // Find and remove from map 33 + for (const [key, value] of this.publishedFollows.entries()) { 34 + if (value.uri === follow.publishedRecordId.uri) { 35 + this.publishedFollows.delete(key); 36 + break; 37 + } 38 + } 39 + 40 + return ok(undefined); 41 + } 42 + 43 + // Test helper 44 + getPublishedFollows(): Map<string, PublishedRecordId> { 45 + return this.publishedFollows; 46 + } 47 + }
+2 -1
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 110 110 updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 111 111 )`, 112 112 113 - // Follows table (no dependencies) 113 + // Follows table (references published_records) 114 114 sql`CREATE TABLE IF NOT EXISTS follows ( 115 115 follower_id TEXT NOT NULL, 116 116 target_id TEXT NOT NULL, 117 117 target_type TEXT NOT NULL, 118 + published_record_id UUID REFERENCES published_records(id), 118 119 created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 119 120 PRIMARY KEY (follower_id, target_id, target_type) 120 121 )`,
+111
src/modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler.ts
··· 1 + import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; 2 + import { UserFollowedTargetEvent } from '../../../user/domain/events/UserFollowedTargetEvent'; 3 + import { Result, ok, err } from '../../../../shared/core/Result'; 4 + import { NotificationService } from '../../domain/services/NotificationService'; 5 + import { IUserRepository } from '../../../user/domain/repositories/IUserRepository'; 6 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 7 + import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 8 + import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; 9 + 10 + export class UserFollowedTargetEventHandler 11 + implements IEventHandler<UserFollowedTargetEvent> 12 + { 13 + constructor( 14 + private notificationService: NotificationService, 15 + private userRepository: IUserRepository, 16 + private collectionRepository: ICollectionRepository, 17 + ) {} 18 + 19 + async handle(event: UserFollowedTargetEvent): Promise<Result<void>> { 20 + try { 21 + const actorIdResult = CuratorId.create(event.followerId.value); 22 + if (actorIdResult.isErr()) { 23 + console.error('Invalid actor ID:', actorIdResult.error); 24 + return err(actorIdResult.error); 25 + } 26 + const actorId = actorIdResult.value; 27 + 28 + if (event.targetType.value === 'USER') { 29 + // User followed another user - notify the followed user 30 + const recipientIdResult = CuratorId.create(event.targetId); 31 + if (recipientIdResult.isErr()) { 32 + console.error('Invalid recipient ID:', recipientIdResult.error); 33 + return err(recipientIdResult.error); 34 + } 35 + const recipientId = recipientIdResult.value; 36 + 37 + // Skip if user is following themselves (shouldn't happen due to validation) 38 + if (actorId.equals(recipientId)) { 39 + return ok(undefined); 40 + } 41 + 42 + const notificationResult = 43 + await this.notificationService.createUserFollowedYouNotification( 44 + recipientId, 45 + actorId, 46 + ); 47 + 48 + if (notificationResult.isErr()) { 49 + console.error( 50 + 'Failed to create user followed notification:', 51 + notificationResult.error, 52 + ); 53 + return err(notificationResult.error); 54 + } 55 + } else if (event.targetType.value === 'COLLECTION') { 56 + // User followed a collection - notify the collection author 57 + const collectionIdResult = CollectionId.createFromString( 58 + event.targetId, 59 + ); 60 + if (collectionIdResult.isErr()) { 61 + console.error('Invalid collection ID:', collectionIdResult.error); 62 + return err(collectionIdResult.error); 63 + } 64 + const collectionId = collectionIdResult.value; 65 + 66 + const collectionResult = 67 + await this.collectionRepository.findById(collectionId); 68 + if (collectionResult.isErr()) { 69 + console.error('Failed to find collection:', collectionResult.error); 70 + return err(collectionResult.error); 71 + } 72 + 73 + const collection = collectionResult.value; 74 + if (!collection) { 75 + console.warn( 76 + 'Collection not found for notification:', 77 + event.targetId, 78 + ); 79 + return ok(undefined); 80 + } 81 + 82 + const recipientId = collection.authorId; 83 + 84 + // Skip if user is following their own collection 85 + if (actorId.equals(recipientId)) { 86 + return ok(undefined); 87 + } 88 + 89 + const notificationResult = 90 + await this.notificationService.createUserFollowedYourCollectionNotification( 91 + recipientId, 92 + actorId, 93 + collectionId, 94 + ); 95 + 96 + if (notificationResult.isErr()) { 97 + console.error( 98 + 'Failed to create collection followed notification:', 99 + notificationResult.error, 100 + ); 101 + return err(notificationResult.error); 102 + } 103 + } 104 + 105 + return ok(undefined); 106 + } catch (error) { 107 + console.error('Error handling UserFollowedTargetEvent:', error); 108 + return err(error as Error); 109 + } 110 + } 111 + }
+49
src/modules/notifications/domain/Notification.ts
··· 12 12 collectionIds?: string[]; 13 13 } 14 14 15 + export interface FollowNotificationMetadata { 16 + targetType: 'USER' | 'COLLECTION'; 17 + targetId?: string; // Collection ID if applicable 18 + } 19 + 15 20 interface NotificationProps { 16 21 recipientUserId: CuratorId; 17 22 actorUserId: CuratorId; ··· 124 129 actorUserId, 125 130 type: typeResult.value, 126 131 metadata, 132 + }); 133 + } 134 + 135 + public static createUserFollowedYou( 136 + recipientUserId: CuratorId, 137 + actorUserId: CuratorId, 138 + ): Result<Notification> { 139 + const typeResult = NotificationType.userFollowedYou(); 140 + if (typeResult.isErr()) { 141 + return err(typeResult.error); 142 + } 143 + 144 + const metadata: FollowNotificationMetadata = { 145 + targetType: 'USER', 146 + }; 147 + 148 + return this.create({ 149 + recipientUserId, 150 + actorUserId, 151 + type: typeResult.value, 152 + metadata: metadata as any, 153 + }); 154 + } 155 + 156 + public static createUserFollowedYourCollection( 157 + recipientUserId: CuratorId, 158 + actorUserId: CuratorId, 159 + collectionId: CollectionId, 160 + ): Result<Notification> { 161 + const typeResult = NotificationType.userFollowedYourCollection(); 162 + if (typeResult.isErr()) { 163 + return err(typeResult.error); 164 + } 165 + 166 + const metadata: FollowNotificationMetadata = { 167 + targetType: 'COLLECTION', 168 + targetId: collectionId.getStringValue(), 169 + }; 170 + 171 + return this.create({ 172 + recipientUserId, 173 + actorUserId, 174 + type: typeResult.value, 175 + metadata: metadata as any, 127 176 }); 128 177 } 129 178
+94
src/modules/notifications/domain/services/NotificationService.ts
··· 115 115 ); 116 116 } 117 117 } 118 + 119 + async createUserFollowedYouNotification( 120 + recipientUserId: CuratorId, 121 + actorUserId: CuratorId, 122 + ): Promise<Result<Notification, NotificationServiceError>> { 123 + try { 124 + // Don't create notification if user is following themselves (shouldn't happen) 125 + if (recipientUserId.equals(actorUserId)) { 126 + return err( 127 + new NotificationServiceError( 128 + 'Cannot notify user about their own action', 129 + ), 130 + ); 131 + } 132 + 133 + const notificationResult = Notification.createUserFollowedYou( 134 + recipientUserId, 135 + actorUserId, 136 + ); 137 + 138 + if (notificationResult.isErr()) { 139 + return err( 140 + new NotificationServiceError(notificationResult.error.message), 141 + ); 142 + } 143 + 144 + const notification = notificationResult.value; 145 + const saveResult = await this.notificationRepository.save(notification); 146 + 147 + if (saveResult.isErr()) { 148 + return err( 149 + new NotificationServiceError( 150 + `Failed to save notification: ${saveResult.error.message}`, 151 + ), 152 + ); 153 + } 154 + 155 + return ok(notification); 156 + } catch (error) { 157 + return err( 158 + new NotificationServiceError( 159 + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, 160 + ), 161 + ); 162 + } 163 + } 164 + 165 + async createUserFollowedYourCollectionNotification( 166 + recipientUserId: CuratorId, 167 + actorUserId: CuratorId, 168 + collectionId: CollectionId, 169 + ): Promise<Result<Notification, NotificationServiceError>> { 170 + try { 171 + // Don't create notification if user is following their own collection 172 + if (recipientUserId.equals(actorUserId)) { 173 + return err( 174 + new NotificationServiceError( 175 + 'Cannot notify user about their own action', 176 + ), 177 + ); 178 + } 179 + 180 + const notificationResult = Notification.createUserFollowedYourCollection( 181 + recipientUserId, 182 + actorUserId, 183 + collectionId, 184 + ); 185 + 186 + if (notificationResult.isErr()) { 187 + return err( 188 + new NotificationServiceError(notificationResult.error.message), 189 + ); 190 + } 191 + 192 + const notification = notificationResult.value; 193 + const saveResult = await this.notificationRepository.save(notification); 194 + 195 + if (saveResult.isErr()) { 196 + return err( 197 + new NotificationServiceError( 198 + `Failed to save notification: ${saveResult.error.message}`, 199 + ), 200 + ); 201 + } 202 + 203 + return ok(notification); 204 + } catch (error) { 205 + return err( 206 + new NotificationServiceError( 207 + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, 208 + ), 209 + ); 210 + } 211 + } 118 212 }
+8
src/modules/notifications/domain/value-objects/NotificationType.ts
··· 37 37 public static userAddedToYourCollection(): Result<NotificationType> { 38 38 return this.create(NotificationTypeEnum.USER_ADDED_TO_YOUR_COLLECTION); 39 39 } 40 + 41 + public static userFollowedYou(): Result<NotificationType> { 42 + return this.create(NotificationTypeEnum.USER_FOLLOWED_YOU); 43 + } 44 + 45 + public static userFollowedYourCollection(): Result<NotificationType> { 46 + return this.create(NotificationTypeEnum.USER_FOLLOWED_YOUR_COLLECTION); 47 + } 40 48 }
+24
src/modules/user/application/ports/IFollowPublisher.ts
··· 1 + import { Result } from '../../../../shared/core/Result'; 2 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 3 + import { Follow } from '../../domain/Follow'; 4 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 5 + 6 + export interface IFollowPublisher { 7 + /** 8 + * Publish a follow relationship to AT Protocol. 9 + * 10 + * @param follow - The Follow domain object to publish 11 + * @returns Published record ID (AT URI + CID) 12 + */ 13 + publishFollow( 14 + follow: Follow, 15 + ): Promise<Result<PublishedRecordId, UseCaseError>>; 16 + 17 + /** 18 + * Unpublish (delete) a follow relationship from AT Protocol. 19 + * 20 + * @param follow - The Follow domain object with publishedRecordId to unpublish 21 + * @returns Success or error 22 + */ 23 + unpublishFollow(follow: Follow): Promise<Result<void, UseCaseError>>; 24 + }
+209
src/modules/user/application/useCases/commands/FollowTargetUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 3 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4 + import { AppError } from '../../../../../shared/core/AppError'; 5 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 6 + import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; 7 + import { IUserRepository } from '../../../domain/repositories/IUserRepository'; 8 + import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; 9 + import { IFollowPublisher } from '../../ports/IFollowPublisher'; 10 + import { DID } from '../../../domain/value-objects/DID'; 11 + import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 12 + import { Follow } from '../../../domain/Follow'; 13 + import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; 14 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 15 + 16 + export interface FollowTargetDTO { 17 + followerId: string; // DID 18 + targetId: string; // DID or Collection UUID 19 + targetType: 'USER' | 'COLLECTION'; 20 + } 21 + 22 + export interface FollowTargetResponseDTO { 23 + followId: string; 24 + } 25 + 26 + export class ValidationError extends UseCaseError { 27 + constructor(message: string) { 28 + super(message); 29 + } 30 + } 31 + 32 + export class FollowTargetUseCase extends BaseUseCase< 33 + FollowTargetDTO, 34 + Result<FollowTargetResponseDTO, ValidationError | AppError.UnexpectedError> 35 + > { 36 + constructor( 37 + private followsRepository: IFollowsRepository, 38 + private userRepository: IUserRepository, 39 + private collectionRepository: ICollectionRepository, 40 + private followPublisher: IFollowPublisher, 41 + eventPublisher: IEventPublisher, 42 + ) { 43 + super(eventPublisher); 44 + } 45 + 46 + async execute( 47 + request: FollowTargetDTO, 48 + ): Promise< 49 + Result<FollowTargetResponseDTO, ValidationError | AppError.UnexpectedError> 50 + > { 51 + try { 52 + // 1. Validate followerId (create DID value object) 53 + const followerDidResult = DID.create(request.followerId); 54 + if (followerDidResult.isErr()) { 55 + return err( 56 + new ValidationError( 57 + `Invalid follower ID: ${followerDidResult.error.message}`, 58 + ), 59 + ); 60 + } 61 + const followerDid = followerDidResult.value; 62 + 63 + // 2. Validate targetType 64 + const targetTypeResult = FollowTargetType.create( 65 + request.targetType as any, 66 + ); 67 + if (targetTypeResult.isErr()) { 68 + return err( 69 + new ValidationError( 70 + `Invalid target type: ${targetTypeResult.error.message}`, 71 + ), 72 + ); 73 + } 74 + const targetType = targetTypeResult.value; 75 + 76 + // 3. Prevent self-follows (only for USER type) 77 + if ( 78 + targetType.value === 'USER' && 79 + request.followerId === request.targetId 80 + ) { 81 + return err(new ValidationError('Users cannot follow themselves')); 82 + } 83 + 84 + // 4. Validate target exists 85 + if (targetType.value === 'USER') { 86 + const targetDidResult = DID.create(request.targetId); 87 + if (targetDidResult.isErr()) { 88 + return err( 89 + new ValidationError( 90 + `Invalid target ID: ${targetDidResult.error.message}`, 91 + ), 92 + ); 93 + } 94 + 95 + const userResult = await this.userRepository.findByDID( 96 + targetDidResult.value, 97 + ); 98 + if (userResult.isErr()) { 99 + return err(AppError.UnexpectedError.create(userResult.error)); 100 + } 101 + 102 + if (!userResult.value) { 103 + return err(new ValidationError('Target user not found')); 104 + } 105 + } else if (targetType.value === 'COLLECTION') { 106 + const collectionIdResult = CollectionId.createFromString( 107 + request.targetId, 108 + ); 109 + if (collectionIdResult.isErr()) { 110 + return err( 111 + new ValidationError( 112 + `Invalid collection ID: ${collectionIdResult.error.message}`, 113 + ), 114 + ); 115 + } 116 + 117 + const collectionResult = await this.collectionRepository.findById( 118 + collectionIdResult.value, 119 + ); 120 + if (collectionResult.isErr()) { 121 + return err(AppError.UnexpectedError.create(collectionResult.error)); 122 + } 123 + 124 + if (!collectionResult.value) { 125 + return err(new ValidationError('Target collection not found')); 126 + } 127 + } 128 + 129 + // 5. Check if already following (idempotent) 130 + const existingFollowResult = 131 + await this.followsRepository.findByFollowerAndTarget( 132 + request.followerId, 133 + request.targetId, 134 + targetType, 135 + ); 136 + 137 + if (existingFollowResult.isErr()) { 138 + return err(AppError.UnexpectedError.create(existingFollowResult.error)); 139 + } 140 + 141 + if (existingFollowResult.value) { 142 + // Already following - return success with existing follow ID 143 + return ok({ 144 + followId: existingFollowResult.value.followId.toString(), 145 + }); 146 + } 147 + 148 + // 6. Create Follow aggregate (does NOT raise event yet) 149 + const followResult = Follow.createNew( 150 + followerDid, 151 + request.targetId, 152 + targetType, 153 + ); 154 + 155 + if (followResult.isErr()) { 156 + return err(new ValidationError(followResult.error.message)); 157 + } 158 + 159 + let follow = followResult.value; 160 + 161 + // 7. Publish to AT Protocol BEFORE saving 162 + const publishResult = await this.followPublisher.publishFollow(follow); 163 + 164 + if (publishResult.isErr()) { 165 + // Propagate authentication errors 166 + if (publishResult.error instanceof AuthenticationError) { 167 + return err(publishResult.error); 168 + } 169 + if (publishResult.error instanceof AppError.UnexpectedError) { 170 + return err(publishResult.error); 171 + } 172 + return err(new ValidationError(publishResult.error.message)); 173 + } 174 + 175 + // 8. Mark follow as published with the returned publishedRecordId 176 + const publishedRecordId = publishResult.value; 177 + follow.markAsPublished(publishedRecordId); 178 + 179 + // 9. Save to repository with publishedRecordId 180 + const saveResult = await this.followsRepository.save(follow); 181 + if (saveResult.isErr()) { 182 + return err(AppError.UnexpectedError.create(saveResult.error)); 183 + } 184 + 185 + // 10. Raise UserFollowedTargetEvent (now that publish succeeded) 186 + const eventResult = follow.raiseFollowedEvent(); 187 + if (eventResult.isErr()) { 188 + console.error('Failed to raise followed event:', eventResult.error); 189 + } 190 + 191 + // 11. Publish domain events 192 + const publishEventsResult = await this.publishEventsForAggregate(follow); 193 + if (publishEventsResult.isErr()) { 194 + console.error( 195 + 'Failed to publish domain events:', 196 + publishEventsResult.error, 197 + ); 198 + // Don't fail the operation 199 + } 200 + 201 + // 12. Return success 202 + return ok({ 203 + followId: follow.followId.toString(), 204 + }); 205 + } catch (error) { 206 + return err(AppError.UnexpectedError.create(error)); 207 + } 208 + } 209 + }
+128
src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../../shared/core/Result'; 2 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 3 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4 + import { AppError } from '../../../../../shared/core/AppError'; 5 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 6 + import { IFollowsRepository } from '../../../domain/repositories/IFollowsRepository'; 7 + import { IFollowPublisher } from '../../ports/IFollowPublisher'; 8 + import { DID } from '../../../domain/value-objects/DID'; 9 + import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 10 + 11 + export interface UnfollowTargetDTO { 12 + followerId: string; // DID 13 + targetId: string; // DID or Collection UUID 14 + targetType: 'USER' | 'COLLECTION'; 15 + } 16 + 17 + export class ValidationError extends UseCaseError { 18 + constructor(message: string) { 19 + super(message); 20 + } 21 + } 22 + 23 + export class UnfollowTargetUseCase extends BaseUseCase< 24 + UnfollowTargetDTO, 25 + Result<void, ValidationError | AppError.UnexpectedError> 26 + > { 27 + constructor( 28 + private followsRepository: IFollowsRepository, 29 + private followPublisher: IFollowPublisher, 30 + eventPublisher: IEventPublisher, 31 + ) { 32 + super(eventPublisher); 33 + } 34 + 35 + async execute( 36 + request: UnfollowTargetDTO, 37 + ): Promise<Result<void, ValidationError | AppError.UnexpectedError>> { 38 + try { 39 + // 1. Validate followerId (create DID value object) 40 + const followerDidResult = DID.create(request.followerId); 41 + if (followerDidResult.isErr()) { 42 + return err( 43 + new ValidationError( 44 + `Invalid follower ID: ${followerDidResult.error.message}`, 45 + ), 46 + ); 47 + } 48 + const followerDid = followerDidResult.value; 49 + 50 + // 2. Validate targetType 51 + const targetTypeResult = FollowTargetType.create( 52 + request.targetType as any, 53 + ); 54 + if (targetTypeResult.isErr()) { 55 + return err( 56 + new ValidationError( 57 + `Invalid target type: ${targetTypeResult.error.message}`, 58 + ), 59 + ); 60 + } 61 + const targetType = targetTypeResult.value; 62 + 63 + // 3. Find existing follow record 64 + const existingFollowResult = 65 + await this.followsRepository.findByFollowerAndTarget( 66 + request.followerId, 67 + request.targetId, 68 + targetType, 69 + ); 70 + 71 + if (existingFollowResult.isErr()) { 72 + return err(AppError.UnexpectedError.create(existingFollowResult.error)); 73 + } 74 + 75 + // 4. If not found, return success (idempotent) 76 + if (!existingFollowResult.value) { 77 + return ok(undefined); 78 + } 79 + 80 + const follow = existingFollowResult.value; 81 + 82 + // 5. Unpublish from AT Protocol (if has publishedRecordId) 83 + if (follow.publishedRecordId) { 84 + const unpublishResult = 85 + await this.followPublisher.unpublishFollow(follow); 86 + if (unpublishResult.isErr()) { 87 + // Log but don't fail - we still want to delete locally 88 + console.error( 89 + 'Failed to unpublish follow from AT Protocol:', 90 + unpublishResult.error, 91 + ); 92 + } 93 + } 94 + 95 + // 6. Call markForRemoval() (raises UserUnfollowedTargetEvent) 96 + const markResult = follow.markForRemoval(); 97 + if (markResult.isErr()) { 98 + return err(new ValidationError(markResult.error.message)); 99 + } 100 + 101 + // 7. Delete from repository 102 + const deleteResult = await this.followsRepository.delete( 103 + request.followerId, 104 + request.targetId, 105 + targetType, 106 + ); 107 + 108 + if (deleteResult.isErr()) { 109 + return err(AppError.UnexpectedError.create(deleteResult.error)); 110 + } 111 + 112 + // 8. Publish domain events 113 + const publishEventsResult = await this.publishEventsForAggregate(follow); 114 + if (publishEventsResult.isErr()) { 115 + console.error( 116 + 'Failed to publish domain events:', 117 + publishEventsResult.error, 118 + ); 119 + // Don't fail the operation 120 + } 121 + 122 + // 9. Return success 123 + return ok(undefined); 124 + } catch (error) { 125 + return err(AppError.UnexpectedError.create(error)); 126 + } 127 + } 128 + }
+45
src/modules/user/domain/Follow.ts
··· 4 4 import { err, ok, Result } from 'src/shared/core/Result'; 5 5 import { DID } from './value-objects/DID'; 6 6 import { FollowTargetType } from './value-objects/FollowTargetType'; 7 + import { PublishedRecordId } from '../../cards/domain/value-objects/PublishedRecordId'; 8 + import { UserUnfollowedTargetEvent } from './events/UserUnfollowedTargetEvent'; 9 + import { UserFollowedTargetEvent } from './events/UserFollowedTargetEvent'; 7 10 8 11 export interface FollowProps { 9 12 followerId: DID; 10 13 targetId: string; 11 14 targetType: FollowTargetType; 15 + publishedRecordId?: PublishedRecordId; 12 16 createdAt: Date; 13 17 } 14 18 ··· 31 35 32 36 get createdAt(): Date { 33 37 return this.props.createdAt; 38 + } 39 + 40 + get publishedRecordId(): PublishedRecordId | undefined { 41 + return this.props.publishedRecordId; 42 + } 43 + 44 + public markAsPublished(publishedRecordId: PublishedRecordId): void { 45 + this.props.publishedRecordId = publishedRecordId; 46 + } 47 + 48 + public markForRemoval(): Result<void> { 49 + const event = UserUnfollowedTargetEvent.create( 50 + this.followId, 51 + this.followerId, 52 + this.targetId, 53 + this.targetType, 54 + ); 55 + 56 + if (event.isErr()) { 57 + return err(new Error(event.error.message)); 58 + } 59 + 60 + this.addDomainEvent(event.value); 61 + return ok(undefined); 62 + } 63 + 64 + public raiseFollowedEvent(): Result<void> { 65 + const event = UserFollowedTargetEvent.create( 66 + this.followId, 67 + this.followerId, 68 + this.targetId, 69 + this.targetType, 70 + this.createdAt, 71 + ); 72 + 73 + if (event.isErr()) { 74 + return err(new Error(event.error.message)); 75 + } 76 + 77 + this.addDomainEvent(event.value); 78 + return ok(undefined); 34 79 } 35 80 36 81 private constructor(props: FollowProps, id?: UniqueEntityID) {
+64
src/modules/user/domain/events/UserFollowedTargetEvent.ts
··· 1 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 2 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 3 + import { DID } from '../value-objects/DID'; 4 + import { FollowTargetType } from '../value-objects/FollowTargetType'; 5 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 6 + import { Result, ok } from '../../../../shared/core/Result'; 7 + 8 + export class UserFollowedTargetEvent implements IDomainEvent { 9 + public readonly eventName = EventNames.USER_FOLLOWED_TARGET; 10 + public readonly dateTimeOccurred: Date; 11 + 12 + private constructor( 13 + public readonly followId: UniqueEntityID, 14 + public readonly followerId: DID, 15 + public readonly targetId: string, 16 + public readonly targetType: FollowTargetType, 17 + public readonly createdAt: Date, 18 + dateTimeOccurred?: Date, 19 + ) { 20 + this.dateTimeOccurred = dateTimeOccurred || new Date(); 21 + } 22 + 23 + public static create( 24 + followId: UniqueEntityID, 25 + followerId: DID, 26 + targetId: string, 27 + targetType: FollowTargetType, 28 + createdAt: Date, 29 + ): Result<UserFollowedTargetEvent> { 30 + return ok( 31 + new UserFollowedTargetEvent( 32 + followId, 33 + followerId, 34 + targetId, 35 + targetType, 36 + createdAt, 37 + ), 38 + ); 39 + } 40 + 41 + public static reconstruct( 42 + followId: UniqueEntityID, 43 + followerId: DID, 44 + targetId: string, 45 + targetType: FollowTargetType, 46 + createdAt: Date, 47 + dateTimeOccurred: Date, 48 + ): Result<UserFollowedTargetEvent> { 49 + return ok( 50 + new UserFollowedTargetEvent( 51 + followId, 52 + followerId, 53 + targetId, 54 + targetType, 55 + createdAt, 56 + dateTimeOccurred, 57 + ), 58 + ); 59 + } 60 + 61 + getAggregateId(): UniqueEntityID { 62 + return this.followId; 63 + } 64 + }
+54
src/modules/user/domain/events/UserUnfollowedTargetEvent.ts
··· 1 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 2 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 3 + import { DID } from '../value-objects/DID'; 4 + import { FollowTargetType } from '../value-objects/FollowTargetType'; 5 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 6 + import { Result, ok } from '../../../../shared/core/Result'; 7 + 8 + export class UserUnfollowedTargetEvent implements IDomainEvent { 9 + public readonly eventName = EventNames.USER_UNFOLLOWED_TARGET; 10 + public readonly dateTimeOccurred: Date; 11 + 12 + private constructor( 13 + public readonly followId: UniqueEntityID, 14 + public readonly followerId: DID, 15 + public readonly targetId: string, 16 + public readonly targetType: FollowTargetType, 17 + dateTimeOccurred?: Date, 18 + ) { 19 + this.dateTimeOccurred = dateTimeOccurred || new Date(); 20 + } 21 + 22 + public static create( 23 + followId: UniqueEntityID, 24 + followerId: DID, 25 + targetId: string, 26 + targetType: FollowTargetType, 27 + ): Result<UserUnfollowedTargetEvent> { 28 + return ok( 29 + new UserUnfollowedTargetEvent(followId, followerId, targetId, targetType), 30 + ); 31 + } 32 + 33 + public static reconstruct( 34 + followId: UniqueEntityID, 35 + followerId: DID, 36 + targetId: string, 37 + targetType: FollowTargetType, 38 + dateTimeOccurred: Date, 39 + ): Result<UserUnfollowedTargetEvent> { 40 + return ok( 41 + new UserUnfollowedTargetEvent( 42 + followId, 43 + followerId, 44 + targetId, 45 + targetType, 46 + dateTimeOccurred, 47 + ), 48 + ); 49 + } 50 + 51 + getAggregateId(): UniqueEntityID { 52 + return this.followId; 53 + } 54 + }
+40
src/modules/user/domain/repositories/IFollowsRepository.ts
··· 38 38 * (deduplication happens at use case level) 39 39 */ 40 40 getFollowersOfCollections(collectionIds: string[]): Promise<Result<Follow[]>>; 41 + 42 + /** 43 + * Save a follow relationship. 44 + * 45 + * @param follow - The follow entity to persist 46 + * @returns Success or error 47 + * 48 + * Idempotency: Uses INSERT ON CONFLICT DO NOTHING on composite key 49 + */ 50 + save(follow: Follow): Promise<Result<void>>; 51 + 52 + /** 53 + * Delete a follow relationship. 54 + * 55 + * @param followerId - DID of the follower 56 + * @param targetId - ID of the target (user DID or collection UUID) 57 + * @param targetType - Type of target 58 + * @returns Success or error 59 + * 60 + * Idempotency: Returns success even if follow doesn't exist 61 + */ 62 + delete( 63 + followerId: string, 64 + targetId: string, 65 + targetType: FollowTargetType, 66 + ): Promise<Result<void>>; 67 + 68 + /** 69 + * Find a specific follow relationship. 70 + * 71 + * @param followerId - DID of the follower 72 + * @param targetId - ID of the target 73 + * @param targetType - Type of target 74 + * @returns Follow entity or null if not found 75 + */ 76 + findByFollowerAndTarget( 77 + followerId: string, 78 + targetId: string, 79 + targetType: FollowTargetType, 80 + ): Promise<Result<Follow | null>>; 41 81 }
+43
src/modules/user/infrastructure/http/controllers/FollowTargetController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { FollowTargetUseCase } from '../../../application/useCases/commands/FollowTargetUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 6 + 7 + export class FollowTargetController extends Controller { 8 + constructor(private followTargetUseCase: FollowTargetUseCase) { 9 + super(); 10 + } 11 + 12 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 13 + try { 14 + const { targetId, targetType } = req.body; 15 + const followerId = req.did; 16 + 17 + if (!followerId) { 18 + return this.unauthorized(res); 19 + } 20 + 21 + if (!targetId || !targetType) { 22 + return this.badRequest(res, 'Target ID and type are required'); 23 + } 24 + 25 + const result = await this.followTargetUseCase.execute({ 26 + followerId, 27 + targetId, 28 + targetType, 29 + }); 30 + 31 + if (result.isErr()) { 32 + if (result.error instanceof AuthenticationError) { 33 + return this.unauthorized(res, result.error.message); 34 + } 35 + return this.fail(res, result.error); 36 + } 37 + 38 + return this.ok(res, result.value); 39 + } catch (error: any) { 40 + return this.handleError(res, error); 41 + } 42 + } 43 + }
+43
src/modules/user/infrastructure/http/controllers/UnfollowTargetController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Response } from 'express'; 3 + import { UnfollowTargetUseCase } from '../../../application/useCases/commands/UnfollowTargetUseCase'; 4 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 6 + 7 + export class UnfollowTargetController extends Controller { 8 + constructor(private unfollowTargetUseCase: UnfollowTargetUseCase) { 9 + super(); 10 + } 11 + 12 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 13 + try { 14 + const { targetId, targetType } = req.params; 15 + const followerId = req.did; 16 + 17 + if (!followerId) { 18 + return this.unauthorized(res); 19 + } 20 + 21 + if (!targetId || !targetType) { 22 + return this.badRequest(res, 'Target ID and type are required'); 23 + } 24 + 25 + const result = await this.unfollowTargetUseCase.execute({ 26 + followerId, 27 + targetId, 28 + targetType: targetType as 'USER' | 'COLLECTION', 29 + }); 30 + 31 + if (result.isErr()) { 32 + if (result.error instanceof AuthenticationError) { 33 + return this.unauthorized(res, result.error.message); 34 + } 35 + return this.fail(res, result.error); 36 + } 37 + 38 + return this.ok(res); 39 + } catch (error: any) { 40 + return this.handleError(res, error); 41 + } 42 + } 43 + }
+15
src/modules/user/infrastructure/http/routes/userRoutes.ts
··· 8 8 import { GetUserProfileController } from 'src/modules/cards/infrastructure/http/controllers/GetUserProfileController'; 9 9 import { LogoutController } from '../controllers/LogoutController'; 10 10 import { GenerateExtensionTokensController } from '../controllers/GenerateExtensionTokensController'; 11 + import { FollowTargetController } from '../controllers/FollowTargetController'; 12 + import { UnfollowTargetController } from '../controllers/UnfollowTargetController'; 11 13 12 14 export const createUserRoutes = ( 13 15 router: Router, ··· 20 22 getUserProfileController: GetUserProfileController, 21 23 refreshAccessTokenController: RefreshAccessTokenController, 22 24 generateExtensionTokensController: GenerateExtensionTokensController, 25 + followTargetController: FollowTargetController, 26 + unfollowTargetController: UnfollowTargetController, 23 27 ) => { 24 28 // Public routes 25 29 router.get('/login', (req, res) => ··· 50 54 '/extension/tokens', 51 55 authMiddleware.ensureAuthenticated(), 52 56 (req, res) => generateExtensionTokensController.execute(req, res), 57 + ); 58 + 59 + // Follow/Unfollow routes 60 + router.post('/follows', authMiddleware.ensureAuthenticated(), (req, res) => 61 + followTargetController.execute(req, res), 62 + ); 63 + 64 + router.delete( 65 + '/follows/:targetId/:targetType', 66 + authMiddleware.ensureAuthenticated(), 67 + (req, res) => unfollowTargetController.execute(req, res), 53 68 ); 54 69 55 70 return router;
+149
src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts
··· 1 1 import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 2 import { eq, and, sql } from 'drizzle-orm'; 3 + import { randomUUID } from 'crypto'; 3 4 import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; 4 5 import { Follow } from '../../domain/Follow'; 5 6 import { DID } from '../../domain/value-objects/DID'; ··· 8 9 FollowTargetTypeEnum, 9 10 } from '../../domain/value-objects/FollowTargetType'; 10 11 import { follows } from './schema/follows.sql'; 12 + import { publishedRecords } from '../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; 13 + import { PublishedRecordId } from '../../../cards/domain/value-objects/PublishedRecordId'; 11 14 import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; 12 15 import { err, ok, Result } from 'src/shared/core/Result'; 13 16 ··· 140 143 return ok(followEntities); 141 144 } catch (error: any) { 142 145 return err(error); 146 + } 147 + } 148 + 149 + async save(follow: Follow): Promise<Result<void>> { 150 + try { 151 + // Handle publishedRecordId persistence (similar to Card repository) 152 + let publishedRecordIdUuid: string | undefined; 153 + if (follow.publishedRecordId) { 154 + const publishedRecordId = follow.publishedRecordId.getValue(); 155 + 156 + // Insert or get existing published record 157 + const existingRecord = await this.db 158 + .select() 159 + .from(publishedRecords) 160 + .where( 161 + and( 162 + eq(publishedRecords.uri, publishedRecordId.uri), 163 + eq(publishedRecords.cid, publishedRecordId.cid), 164 + ), 165 + ) 166 + .limit(1); 167 + 168 + if (existingRecord.length > 0) { 169 + publishedRecordIdUuid = existingRecord[0]!.id; 170 + } else { 171 + const insertResult = await this.db 172 + .insert(publishedRecords) 173 + .values({ 174 + id: randomUUID(), 175 + uri: publishedRecordId.uri, 176 + cid: publishedRecordId.cid, 177 + }) 178 + .returning({ id: publishedRecords.id }); 179 + publishedRecordIdUuid = insertResult[0]!.id; 180 + } 181 + } 182 + 183 + await this.db 184 + .insert(follows) 185 + .values({ 186 + followerId: follow.followerId.value, 187 + targetId: follow.targetId, 188 + targetType: follow.targetType.value, 189 + publishedRecordId: publishedRecordIdUuid, 190 + createdAt: follow.createdAt, 191 + }) 192 + .onConflictDoUpdate({ 193 + target: [follows.followerId, follows.targetId, follows.targetType], 194 + set: { 195 + publishedRecordId: publishedRecordIdUuid, 196 + }, 197 + }); 198 + 199 + return ok(undefined); 200 + } catch (error) { 201 + return err(error as Error); 202 + } 203 + } 204 + 205 + async delete( 206 + followerId: string, 207 + targetId: string, 208 + targetType: FollowTargetType, 209 + ): Promise<Result<void>> { 210 + try { 211 + await this.db 212 + .delete(follows) 213 + .where( 214 + and( 215 + eq(follows.followerId, followerId), 216 + eq(follows.targetId, targetId), 217 + eq(follows.targetType, targetType.value), 218 + ), 219 + ); 220 + 221 + return ok(undefined); 222 + } catch (error) { 223 + return err(error as Error); 224 + } 225 + } 226 + 227 + async findByFollowerAndTarget( 228 + followerId: string, 229 + targetId: string, 230 + targetType: FollowTargetType, 231 + ): Promise<Result<Follow | null>> { 232 + try { 233 + const results = await this.db 234 + .select({ 235 + follow: follows, 236 + publishedRecord: publishedRecords, 237 + }) 238 + .from(follows) 239 + .leftJoin( 240 + publishedRecords, 241 + eq(follows.publishedRecordId, publishedRecords.id), 242 + ) 243 + .where( 244 + and( 245 + eq(follows.followerId, followerId), 246 + eq(follows.targetId, targetId), 247 + eq(follows.targetType, targetType.value), 248 + ), 249 + ) 250 + .limit(1); 251 + 252 + if (results.length === 0) { 253 + return ok(null); 254 + } 255 + 256 + const row = results[0]!; 257 + const followerDid = DID.create(row.follow.followerId); 258 + if (followerDid.isErr()) { 259 + return err(followerDid.error); 260 + } 261 + 262 + const targetTypeVO = FollowTargetType.create( 263 + row.follow.targetType as any, 264 + ); 265 + if (targetTypeVO.isErr()) { 266 + return err(targetTypeVO.error); 267 + } 268 + 269 + // Reconstruct publishedRecordId if present 270 + let publishedRecordId: PublishedRecordId | undefined; 271 + if (row.publishedRecord) { 272 + publishedRecordId = PublishedRecordId.create({ 273 + uri: row.publishedRecord.uri, 274 + cid: row.publishedRecord.cid, 275 + }); 276 + } 277 + 278 + return Follow.create( 279 + { 280 + followerId: followerDid.value, 281 + targetId: row.follow.targetId, 282 + targetType: targetTypeVO.value, 283 + publishedRecordId, 284 + createdAt: row.follow.createdAt, 285 + }, 286 + new UniqueEntityID( 287 + `${row.follow.followerId}:${row.follow.targetId}:${row.follow.targetType}`, 288 + ), 289 + ); 290 + } catch (error) { 291 + return err(error as Error); 143 292 } 144 293 } 145 294 }
+5
src/modules/user/infrastructure/repositories/schema/follows.sql.ts
··· 4 4 timestamp, 5 5 primaryKey, 6 6 index, 7 + uuid, 7 8 } from 'drizzle-orm/pg-core'; 9 + import { publishedRecords } from '../../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; 8 10 9 11 export const follows = pgTable( 10 12 'follows', ··· 12 14 followerId: text('follower_id').notNull(), 13 15 targetId: text('target_id').notNull(), 14 16 targetType: text('target_type').notNull(), 17 + publishedRecordId: uuid('published_record_id').references( 18 + () => publishedRecords.id, 19 + ), 15 20 createdAt: timestamp('created_at').notNull().defaultNow(), 16 21 }, 17 22 (table) => ({
+28
src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts
··· 77 77 } 78 78 } 79 79 80 + async delete( 81 + followerId: string, 82 + targetId: string, 83 + targetType: FollowTargetType, 84 + ): Promise<Result<void>> { 85 + try { 86 + const key = `${followerId}:${targetId}:${targetType.value}`; 87 + this.follows.delete(key); 88 + return ok(undefined); 89 + } catch (error: any) { 90 + return err(error); 91 + } 92 + } 93 + 94 + async findByFollowerAndTarget( 95 + followerId: string, 96 + targetId: string, 97 + targetType: FollowTargetType, 98 + ): Promise<Result<Follow | null>> { 99 + try { 100 + const key = `${followerId}:${targetId}:${targetType.value}`; 101 + const follow = this.follows.get(key); 102 + return ok(follow || null); 103 + } catch (error: any) { 104 + return err(error); 105 + } 106 + } 107 + 80 108 // Helper method for testing 81 109 clear(): void { 82 110 this.follows.clear();
+1
src/shared/constants/atproto.ts
··· 15 15 COLLECTION: 'network.cosmik.collection', 16 16 COLLECTION_LINK: 'network.cosmik.collectionLink', 17 17 COLLECTION_LINK_REMOVAL: 'network.cosmik.collectionLinkRemoval', 18 + FOLLOW: 'network.cosmik.follow', 18 19 }, 19 20 } as const;
+5
src/shared/infrastructure/config/EnvironmentConfigService.ts
··· 36 36 marginCollection: string; 37 37 marginCollectionItem: string; 38 38 collectionLinkRemoval: string; 39 + follow: string; 39 40 }; 40 41 serviceAccount: { 41 42 identifier: string; ··· 128 129 environment === Environment.PROD 129 130 ? ATPROTO_NSID.COSMIK.COLLECTION_LINK_REMOVAL 130 131 : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.collectionLinkRemoval`, 132 + follow: 133 + environment === Environment.PROD 134 + ? ATPROTO_NSID.COSMIK.FOLLOW 135 + : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.follow`, 131 136 // Margin collections - no environment suffix 132 137 marginBookmark: ATPROTO_NSID.MARGIN.BOOKMARK, 133 138 marginCollection: ATPROTO_NSID.MARGIN.COLLECTION,
+2
src/shared/infrastructure/database/migrations/0017_heavy_inertia.sql
··· 1 + ALTER TABLE "follows" ADD COLUMN "published_record_id" uuid;--> statement-breakpoint 2 + ALTER TABLE "follows" ADD CONSTRAINT "follows_published_record_id_published_records_id_fk" FOREIGN KEY ("published_record_id") REFERENCES "public"."published_records"("id") ON DELETE no action ON UPDATE no action;
+1574
src/shared/infrastructure/database/migrations/meta/0017_snapshot.json
··· 1 + { 2 + "id": "3c3e6afe-11db-4e0c-918b-e358b4a761e4", 3 + "prevId": "1186ff74-4783-4cc7-94c4-ae3e8f93ac59", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "url_type": { 87 + "name": "url_type", 88 + "type": "text", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "parent_card_id": { 93 + "name": "parent_card_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "via_card_id": { 99 + "name": "via_card_id", 100 + "type": "uuid", 101 + "primaryKey": false, 102 + "notNull": false 103 + }, 104 + "published_record_id": { 105 + "name": "published_record_id", 106 + "type": "uuid", 107 + "primaryKey": false, 108 + "notNull": false 109 + }, 110 + "library_count": { 111 + "name": "library_count", 112 + "type": "integer", 113 + "primaryKey": false, 114 + "notNull": true, 115 + "default": 0 116 + }, 117 + "created_at": { 118 + "name": "created_at", 119 + "type": "timestamp", 120 + "primaryKey": false, 121 + "notNull": true, 122 + "default": "now()" 123 + }, 124 + "updated_at": { 125 + "name": "updated_at", 126 + "type": "timestamp", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "default": "now()" 130 + } 131 + }, 132 + "indexes": { 133 + "cards_author_url_idx": { 134 + "name": "cards_author_url_idx", 135 + "columns": [ 136 + { 137 + "expression": "author_id", 138 + "isExpression": false, 139 + "asc": true, 140 + "nulls": "last" 141 + }, 142 + { 143 + "expression": "url", 144 + "isExpression": false, 145 + "asc": true, 146 + "nulls": "last" 147 + } 148 + ], 149 + "isUnique": false, 150 + "concurrently": false, 151 + "method": "btree", 152 + "with": {} 153 + }, 154 + "cards_author_id_idx": { 155 + "name": "cards_author_id_idx", 156 + "columns": [ 157 + { 158 + "expression": "author_id", 159 + "isExpression": false, 160 + "asc": true, 161 + "nulls": "last" 162 + } 163 + ], 164 + "isUnique": false, 165 + "concurrently": false, 166 + "method": "btree", 167 + "with": {} 168 + }, 169 + "idx_cards_type_updated_at": { 170 + "name": "idx_cards_type_updated_at", 171 + "columns": [ 172 + { 173 + "expression": "type", 174 + "isExpression": false, 175 + "asc": true, 176 + "nulls": "last" 177 + }, 178 + { 179 + "expression": "updated_at", 180 + "isExpression": false, 181 + "asc": false, 182 + "nulls": "last" 183 + } 184 + ], 185 + "isUnique": false, 186 + "concurrently": false, 187 + "method": "btree", 188 + "with": {} 189 + }, 190 + "idx_cards_url_type": { 191 + "name": "idx_cards_url_type", 192 + "columns": [ 193 + { 194 + "expression": "url", 195 + "isExpression": false, 196 + "asc": true, 197 + "nulls": "last" 198 + }, 199 + { 200 + "expression": "type", 201 + "isExpression": false, 202 + "asc": true, 203 + "nulls": "last" 204 + } 205 + ], 206 + "isUnique": false, 207 + "concurrently": false, 208 + "method": "btree", 209 + "with": {} 210 + }, 211 + "idx_cards_url_type_filter": { 212 + "name": "idx_cards_url_type_filter", 213 + "columns": [ 214 + { 215 + "expression": "url_type", 216 + "isExpression": false, 217 + "asc": true, 218 + "nulls": "last" 219 + } 220 + ], 221 + "isUnique": false, 222 + "concurrently": false, 223 + "method": "btree", 224 + "with": {} 225 + }, 226 + "idx_cards_parent_type": { 227 + "name": "idx_cards_parent_type", 228 + "columns": [ 229 + { 230 + "expression": "parent_card_id", 231 + "isExpression": false, 232 + "asc": true, 233 + "nulls": "last" 234 + }, 235 + { 236 + "expression": "type", 237 + "isExpression": false, 238 + "asc": true, 239 + "nulls": "last" 240 + } 241 + ], 242 + "isUnique": false, 243 + "where": "type = 'NOTE'", 244 + "concurrently": false, 245 + "method": "btree", 246 + "with": {} 247 + } 248 + }, 249 + "foreignKeys": { 250 + "cards_parent_card_id_cards_id_fk": { 251 + "name": "cards_parent_card_id_cards_id_fk", 252 + "tableFrom": "cards", 253 + "tableTo": "cards", 254 + "columnsFrom": ["parent_card_id"], 255 + "columnsTo": ["id"], 256 + "onDelete": "no action", 257 + "onUpdate": "no action" 258 + }, 259 + "cards_via_card_id_cards_id_fk": { 260 + "name": "cards_via_card_id_cards_id_fk", 261 + "tableFrom": "cards", 262 + "tableTo": "cards", 263 + "columnsFrom": ["via_card_id"], 264 + "columnsTo": ["id"], 265 + "onDelete": "no action", 266 + "onUpdate": "no action" 267 + }, 268 + "cards_published_record_id_published_records_id_fk": { 269 + "name": "cards_published_record_id_published_records_id_fk", 270 + "tableFrom": "cards", 271 + "tableTo": "published_records", 272 + "columnsFrom": ["published_record_id"], 273 + "columnsTo": ["id"], 274 + "onDelete": "no action", 275 + "onUpdate": "no action" 276 + } 277 + }, 278 + "compositePrimaryKeys": {}, 279 + "uniqueConstraints": {}, 280 + "policies": {}, 281 + "checkConstraints": {}, 282 + "isRLSEnabled": false 283 + }, 284 + "public.collection_cards": { 285 + "name": "collection_cards", 286 + "schema": "", 287 + "columns": { 288 + "id": { 289 + "name": "id", 290 + "type": "uuid", 291 + "primaryKey": true, 292 + "notNull": true 293 + }, 294 + "collection_id": { 295 + "name": "collection_id", 296 + "type": "uuid", 297 + "primaryKey": false, 298 + "notNull": true 299 + }, 300 + "card_id": { 301 + "name": "card_id", 302 + "type": "uuid", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "added_by": { 307 + "name": "added_by", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "added_at": { 313 + "name": "added_at", 314 + "type": "timestamp", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "default": "now()" 318 + }, 319 + "via_card_id": { 320 + "name": "via_card_id", 321 + "type": "uuid", 322 + "primaryKey": false, 323 + "notNull": false 324 + }, 325 + "published_record_id": { 326 + "name": "published_record_id", 327 + "type": "uuid", 328 + "primaryKey": false, 329 + "notNull": false 330 + } 331 + }, 332 + "indexes": { 333 + "collection_cards_card_id_idx": { 334 + "name": "collection_cards_card_id_idx", 335 + "columns": [ 336 + { 337 + "expression": "card_id", 338 + "isExpression": false, 339 + "asc": true, 340 + "nulls": "last" 341 + } 342 + ], 343 + "isUnique": false, 344 + "concurrently": false, 345 + "method": "btree", 346 + "with": {} 347 + }, 348 + "collection_cards_collection_id_idx": { 349 + "name": "collection_cards_collection_id_idx", 350 + "columns": [ 351 + { 352 + "expression": "collection_id", 353 + "isExpression": false, 354 + "asc": true, 355 + "nulls": "last" 356 + } 357 + ], 358 + "isUnique": false, 359 + "concurrently": false, 360 + "method": "btree", 361 + "with": {} 362 + }, 363 + "idx_collection_cards_collection_added": { 364 + "name": "idx_collection_cards_collection_added", 365 + "columns": [ 366 + { 367 + "expression": "collection_id", 368 + "isExpression": false, 369 + "asc": true, 370 + "nulls": "last" 371 + }, 372 + { 373 + "expression": "added_at", 374 + "isExpression": false, 375 + "asc": false, 376 + "nulls": "last" 377 + } 378 + ], 379 + "isUnique": false, 380 + "concurrently": false, 381 + "method": "btree", 382 + "with": {} 383 + }, 384 + "idx_collection_cards_card_collection": { 385 + "name": "idx_collection_cards_card_collection", 386 + "columns": [ 387 + { 388 + "expression": "card_id", 389 + "isExpression": false, 390 + "asc": true, 391 + "nulls": "last" 392 + } 393 + ], 394 + "isUnique": false, 395 + "concurrently": false, 396 + "method": "btree", 397 + "with": {} 398 + }, 399 + "idx_collection_cards_added_by_added_at": { 400 + "name": "idx_collection_cards_added_by_added_at", 401 + "columns": [ 402 + { 403 + "expression": "added_by", 404 + "isExpression": false, 405 + "asc": true, 406 + "nulls": "last" 407 + }, 408 + { 409 + "expression": "added_at", 410 + "isExpression": false, 411 + "asc": false, 412 + "nulls": "last" 413 + } 414 + ], 415 + "isUnique": false, 416 + "concurrently": false, 417 + "method": "btree", 418 + "with": {} 419 + } 420 + }, 421 + "foreignKeys": { 422 + "collection_cards_collection_id_collections_id_fk": { 423 + "name": "collection_cards_collection_id_collections_id_fk", 424 + "tableFrom": "collection_cards", 425 + "tableTo": "collections", 426 + "columnsFrom": ["collection_id"], 427 + "columnsTo": ["id"], 428 + "onDelete": "cascade", 429 + "onUpdate": "no action" 430 + }, 431 + "collection_cards_card_id_cards_id_fk": { 432 + "name": "collection_cards_card_id_cards_id_fk", 433 + "tableFrom": "collection_cards", 434 + "tableTo": "cards", 435 + "columnsFrom": ["card_id"], 436 + "columnsTo": ["id"], 437 + "onDelete": "cascade", 438 + "onUpdate": "no action" 439 + }, 440 + "collection_cards_via_card_id_cards_id_fk": { 441 + "name": "collection_cards_via_card_id_cards_id_fk", 442 + "tableFrom": "collection_cards", 443 + "tableTo": "cards", 444 + "columnsFrom": ["via_card_id"], 445 + "columnsTo": ["id"], 446 + "onDelete": "no action", 447 + "onUpdate": "no action" 448 + }, 449 + "collection_cards_published_record_id_published_records_id_fk": { 450 + "name": "collection_cards_published_record_id_published_records_id_fk", 451 + "tableFrom": "collection_cards", 452 + "tableTo": "published_records", 453 + "columnsFrom": ["published_record_id"], 454 + "columnsTo": ["id"], 455 + "onDelete": "no action", 456 + "onUpdate": "no action" 457 + } 458 + }, 459 + "compositePrimaryKeys": {}, 460 + "uniqueConstraints": {}, 461 + "policies": {}, 462 + "checkConstraints": {}, 463 + "isRLSEnabled": false 464 + }, 465 + "public.collection_collaborators": { 466 + "name": "collection_collaborators", 467 + "schema": "", 468 + "columns": { 469 + "id": { 470 + "name": "id", 471 + "type": "uuid", 472 + "primaryKey": true, 473 + "notNull": true 474 + }, 475 + "collection_id": { 476 + "name": "collection_id", 477 + "type": "uuid", 478 + "primaryKey": false, 479 + "notNull": true 480 + }, 481 + "collaborator_id": { 482 + "name": "collaborator_id", 483 + "type": "text", 484 + "primaryKey": false, 485 + "notNull": true 486 + } 487 + }, 488 + "indexes": {}, 489 + "foreignKeys": { 490 + "collection_collaborators_collection_id_collections_id_fk": { 491 + "name": "collection_collaborators_collection_id_collections_id_fk", 492 + "tableFrom": "collection_collaborators", 493 + "tableTo": "collections", 494 + "columnsFrom": ["collection_id"], 495 + "columnsTo": ["id"], 496 + "onDelete": "cascade", 497 + "onUpdate": "no action" 498 + } 499 + }, 500 + "compositePrimaryKeys": {}, 501 + "uniqueConstraints": {}, 502 + "policies": {}, 503 + "checkConstraints": {}, 504 + "isRLSEnabled": false 505 + }, 506 + "public.collections": { 507 + "name": "collections", 508 + "schema": "", 509 + "columns": { 510 + "id": { 511 + "name": "id", 512 + "type": "uuid", 513 + "primaryKey": true, 514 + "notNull": true 515 + }, 516 + "author_id": { 517 + "name": "author_id", 518 + "type": "text", 519 + "primaryKey": false, 520 + "notNull": true 521 + }, 522 + "name": { 523 + "name": "name", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true 527 + }, 528 + "description": { 529 + "name": "description", 530 + "type": "text", 531 + "primaryKey": false, 532 + "notNull": false 533 + }, 534 + "access_type": { 535 + "name": "access_type", 536 + "type": "text", 537 + "primaryKey": false, 538 + "notNull": true 539 + }, 540 + "card_count": { 541 + "name": "card_count", 542 + "type": "integer", 543 + "primaryKey": false, 544 + "notNull": true, 545 + "default": 0 546 + }, 547 + "created_at": { 548 + "name": "created_at", 549 + "type": "timestamp", 550 + "primaryKey": false, 551 + "notNull": true, 552 + "default": "now()" 553 + }, 554 + "updated_at": { 555 + "name": "updated_at", 556 + "type": "timestamp", 557 + "primaryKey": false, 558 + "notNull": true, 559 + "default": "now()" 560 + }, 561 + "published_record_id": { 562 + "name": "published_record_id", 563 + "type": "uuid", 564 + "primaryKey": false, 565 + "notNull": false 566 + } 567 + }, 568 + "indexes": { 569 + "collections_author_id_idx": { 570 + "name": "collections_author_id_idx", 571 + "columns": [ 572 + { 573 + "expression": "author_id", 574 + "isExpression": false, 575 + "asc": true, 576 + "nulls": "last" 577 + } 578 + ], 579 + "isUnique": false, 580 + "concurrently": false, 581 + "method": "btree", 582 + "with": {} 583 + }, 584 + "collections_author_updated_at_idx": { 585 + "name": "collections_author_updated_at_idx", 586 + "columns": [ 587 + { 588 + "expression": "author_id", 589 + "isExpression": false, 590 + "asc": true, 591 + "nulls": "last" 592 + }, 593 + { 594 + "expression": "updated_at", 595 + "isExpression": false, 596 + "asc": true, 597 + "nulls": "last" 598 + } 599 + ], 600 + "isUnique": false, 601 + "concurrently": false, 602 + "method": "btree", 603 + "with": {} 604 + } 605 + }, 606 + "foreignKeys": { 607 + "collections_published_record_id_published_records_id_fk": { 608 + "name": "collections_published_record_id_published_records_id_fk", 609 + "tableFrom": "collections", 610 + "tableTo": "published_records", 611 + "columnsFrom": ["published_record_id"], 612 + "columnsTo": ["id"], 613 + "onDelete": "no action", 614 + "onUpdate": "no action" 615 + } 616 + }, 617 + "compositePrimaryKeys": {}, 618 + "uniqueConstraints": {}, 619 + "policies": {}, 620 + "checkConstraints": {}, 621 + "isRLSEnabled": false 622 + }, 623 + "public.library_memberships": { 624 + "name": "library_memberships", 625 + "schema": "", 626 + "columns": { 627 + "card_id": { 628 + "name": "card_id", 629 + "type": "uuid", 630 + "primaryKey": false, 631 + "notNull": true 632 + }, 633 + "user_id": { 634 + "name": "user_id", 635 + "type": "text", 636 + "primaryKey": false, 637 + "notNull": true 638 + }, 639 + "added_at": { 640 + "name": "added_at", 641 + "type": "timestamp", 642 + "primaryKey": false, 643 + "notNull": true, 644 + "default": "now()" 645 + }, 646 + "published_record_id": { 647 + "name": "published_record_id", 648 + "type": "uuid", 649 + "primaryKey": false, 650 + "notNull": false 651 + } 652 + }, 653 + "indexes": { 654 + "idx_user_cards": { 655 + "name": "idx_user_cards", 656 + "columns": [ 657 + { 658 + "expression": "user_id", 659 + "isExpression": false, 660 + "asc": true, 661 + "nulls": "last" 662 + } 663 + ], 664 + "isUnique": false, 665 + "concurrently": false, 666 + "method": "btree", 667 + "with": {} 668 + }, 669 + "idx_card_users": { 670 + "name": "idx_card_users", 671 + "columns": [ 672 + { 673 + "expression": "card_id", 674 + "isExpression": false, 675 + "asc": true, 676 + "nulls": "last" 677 + } 678 + ], 679 + "isUnique": false, 680 + "concurrently": false, 681 + "method": "btree", 682 + "with": {} 683 + }, 684 + "idx_library_memberships_user_type_covering": { 685 + "name": "idx_library_memberships_user_type_covering", 686 + "columns": [ 687 + { 688 + "expression": "user_id", 689 + "isExpression": false, 690 + "asc": true, 691 + "nulls": "last" 692 + }, 693 + { 694 + "expression": "added_at", 695 + "isExpression": false, 696 + "asc": false, 697 + "nulls": "last" 698 + } 699 + ], 700 + "isUnique": false, 701 + "concurrently": false, 702 + "method": "btree", 703 + "with": {} 704 + } 705 + }, 706 + "foreignKeys": { 707 + "library_memberships_card_id_cards_id_fk": { 708 + "name": "library_memberships_card_id_cards_id_fk", 709 + "tableFrom": "library_memberships", 710 + "tableTo": "cards", 711 + "columnsFrom": ["card_id"], 712 + "columnsTo": ["id"], 713 + "onDelete": "cascade", 714 + "onUpdate": "no action" 715 + }, 716 + "library_memberships_published_record_id_published_records_id_fk": { 717 + "name": "library_memberships_published_record_id_published_records_id_fk", 718 + "tableFrom": "library_memberships", 719 + "tableTo": "published_records", 720 + "columnsFrom": ["published_record_id"], 721 + "columnsTo": ["id"], 722 + "onDelete": "no action", 723 + "onUpdate": "no action" 724 + } 725 + }, 726 + "compositePrimaryKeys": { 727 + "library_memberships_card_id_user_id_pk": { 728 + "name": "library_memberships_card_id_user_id_pk", 729 + "columns": ["card_id", "user_id"] 730 + } 731 + }, 732 + "uniqueConstraints": {}, 733 + "policies": {}, 734 + "checkConstraints": {}, 735 + "isRLSEnabled": false 736 + }, 737 + "public.published_records": { 738 + "name": "published_records", 739 + "schema": "", 740 + "columns": { 741 + "id": { 742 + "name": "id", 743 + "type": "uuid", 744 + "primaryKey": true, 745 + "notNull": true 746 + }, 747 + "uri": { 748 + "name": "uri", 749 + "type": "text", 750 + "primaryKey": false, 751 + "notNull": true 752 + }, 753 + "cid": { 754 + "name": "cid", 755 + "type": "text", 756 + "primaryKey": false, 757 + "notNull": true 758 + }, 759 + "recorded_at": { 760 + "name": "recorded_at", 761 + "type": "timestamp", 762 + "primaryKey": false, 763 + "notNull": true, 764 + "default": "now()" 765 + } 766 + }, 767 + "indexes": { 768 + "uri_cid_unique_idx": { 769 + "name": "uri_cid_unique_idx", 770 + "columns": [ 771 + { 772 + "expression": "uri", 773 + "isExpression": false, 774 + "asc": true, 775 + "nulls": "last" 776 + }, 777 + { 778 + "expression": "cid", 779 + "isExpression": false, 780 + "asc": true, 781 + "nulls": "last" 782 + } 783 + ], 784 + "isUnique": true, 785 + "concurrently": false, 786 + "method": "btree", 787 + "with": {} 788 + }, 789 + "published_records_uri_idx": { 790 + "name": "published_records_uri_idx", 791 + "columns": [ 792 + { 793 + "expression": "uri", 794 + "isExpression": false, 795 + "asc": true, 796 + "nulls": "last" 797 + } 798 + ], 799 + "isUnique": false, 800 + "concurrently": false, 801 + "method": "btree", 802 + "with": {} 803 + } 804 + }, 805 + "foreignKeys": {}, 806 + "compositePrimaryKeys": {}, 807 + "uniqueConstraints": {}, 808 + "policies": {}, 809 + "checkConstraints": {}, 810 + "isRLSEnabled": false 811 + }, 812 + "public.feed_activities": { 813 + "name": "feed_activities", 814 + "schema": "", 815 + "columns": { 816 + "id": { 817 + "name": "id", 818 + "type": "uuid", 819 + "primaryKey": true, 820 + "notNull": true 821 + }, 822 + "actor_id": { 823 + "name": "actor_id", 824 + "type": "text", 825 + "primaryKey": false, 826 + "notNull": true 827 + }, 828 + "card_id": { 829 + "name": "card_id", 830 + "type": "text", 831 + "primaryKey": false, 832 + "notNull": false 833 + }, 834 + "type": { 835 + "name": "type", 836 + "type": "text", 837 + "primaryKey": false, 838 + "notNull": true 839 + }, 840 + "metadata": { 841 + "name": "metadata", 842 + "type": "jsonb", 843 + "primaryKey": false, 844 + "notNull": true 845 + }, 846 + "url_type": { 847 + "name": "url_type", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false 851 + }, 852 + "source": { 853 + "name": "source", 854 + "type": "text", 855 + "primaryKey": false, 856 + "notNull": false 857 + }, 858 + "created_at": { 859 + "name": "created_at", 860 + "type": "timestamp", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "default": "now()" 864 + } 865 + }, 866 + "indexes": { 867 + "feed_activities_type_idx": { 868 + "name": "feed_activities_type_idx", 869 + "columns": [ 870 + { 871 + "expression": "type", 872 + "isExpression": false, 873 + "asc": true, 874 + "nulls": "last" 875 + } 876 + ], 877 + "isUnique": false, 878 + "concurrently": false, 879 + "method": "btree", 880 + "with": {} 881 + }, 882 + "feed_activities_url_type_idx": { 883 + "name": "feed_activities_url_type_idx", 884 + "columns": [ 885 + { 886 + "expression": "url_type", 887 + "isExpression": false, 888 + "asc": true, 889 + "nulls": "last" 890 + } 891 + ], 892 + "isUnique": false, 893 + "concurrently": false, 894 + "method": "btree", 895 + "with": {} 896 + }, 897 + "feed_activities_created_at_idx": { 898 + "name": "feed_activities_created_at_idx", 899 + "columns": [ 900 + { 901 + "expression": "created_at", 902 + "isExpression": false, 903 + "asc": false, 904 + "nulls": "last" 905 + } 906 + ], 907 + "isUnique": false, 908 + "concurrently": false, 909 + "method": "btree", 910 + "with": {} 911 + }, 912 + "feed_activities_type_created_at_idx": { 913 + "name": "feed_activities_type_created_at_idx", 914 + "columns": [ 915 + { 916 + "expression": "type", 917 + "isExpression": false, 918 + "asc": true, 919 + "nulls": "last" 920 + }, 921 + { 922 + "expression": "created_at", 923 + "isExpression": false, 924 + "asc": false, 925 + "nulls": "last" 926 + } 927 + ], 928 + "isUnique": false, 929 + "concurrently": false, 930 + "method": "btree", 931 + "with": {} 932 + }, 933 + "feed_activities_url_type_created_at_idx": { 934 + "name": "feed_activities_url_type_created_at_idx", 935 + "columns": [ 936 + { 937 + "expression": "url_type", 938 + "isExpression": false, 939 + "asc": true, 940 + "nulls": "last" 941 + }, 942 + { 943 + "expression": "created_at", 944 + "isExpression": false, 945 + "asc": false, 946 + "nulls": "last" 947 + } 948 + ], 949 + "isUnique": false, 950 + "concurrently": false, 951 + "method": "btree", 952 + "with": {} 953 + }, 954 + "feed_activities_type_url_type_created_at_idx": { 955 + "name": "feed_activities_type_url_type_created_at_idx", 956 + "columns": [ 957 + { 958 + "expression": "type", 959 + "isExpression": false, 960 + "asc": true, 961 + "nulls": "last" 962 + }, 963 + { 964 + "expression": "url_type", 965 + "isExpression": false, 966 + "asc": true, 967 + "nulls": "last" 968 + }, 969 + { 970 + "expression": "created_at", 971 + "isExpression": false, 972 + "asc": false, 973 + "nulls": "last" 974 + } 975 + ], 976 + "isUnique": false, 977 + "concurrently": false, 978 + "method": "btree", 979 + "with": {} 980 + }, 981 + "feed_activities_dedup_idx": { 982 + "name": "feed_activities_dedup_idx", 983 + "columns": [ 984 + { 985 + "expression": "actor_id", 986 + "isExpression": false, 987 + "asc": true, 988 + "nulls": "last" 989 + }, 990 + { 991 + "expression": "card_id", 992 + "isExpression": false, 993 + "asc": true, 994 + "nulls": "last" 995 + }, 996 + { 997 + "expression": "created_at", 998 + "isExpression": false, 999 + "asc": false, 1000 + "nulls": "last" 1001 + } 1002 + ], 1003 + "isUnique": false, 1004 + "concurrently": false, 1005 + "method": "btree", 1006 + "with": {} 1007 + }, 1008 + "feed_activities_card_id_idx": { 1009 + "name": "feed_activities_card_id_idx", 1010 + "columns": [ 1011 + { 1012 + "expression": "card_id", 1013 + "isExpression": false, 1014 + "asc": true, 1015 + "nulls": "last" 1016 + } 1017 + ], 1018 + "isUnique": false, 1019 + "concurrently": false, 1020 + "method": "btree", 1021 + "with": {} 1022 + }, 1023 + "feed_activities_source_idx": { 1024 + "name": "feed_activities_source_idx", 1025 + "columns": [ 1026 + { 1027 + "expression": "source", 1028 + "isExpression": false, 1029 + "asc": true, 1030 + "nulls": "last" 1031 + } 1032 + ], 1033 + "isUnique": false, 1034 + "concurrently": false, 1035 + "method": "btree", 1036 + "with": {} 1037 + } 1038 + }, 1039 + "foreignKeys": {}, 1040 + "compositePrimaryKeys": {}, 1041 + "uniqueConstraints": {}, 1042 + "policies": {}, 1043 + "checkConstraints": {}, 1044 + "isRLSEnabled": false 1045 + }, 1046 + "public.following_feed_items": { 1047 + "name": "following_feed_items", 1048 + "schema": "", 1049 + "columns": { 1050 + "user_id": { 1051 + "name": "user_id", 1052 + "type": "text", 1053 + "primaryKey": false, 1054 + "notNull": true 1055 + }, 1056 + "activity_id": { 1057 + "name": "activity_id", 1058 + "type": "uuid", 1059 + "primaryKey": false, 1060 + "notNull": true 1061 + }, 1062 + "created_at": { 1063 + "name": "created_at", 1064 + "type": "timestamp", 1065 + "primaryKey": false, 1066 + "notNull": true 1067 + } 1068 + }, 1069 + "indexes": { 1070 + "idx_following_feed_user_time": { 1071 + "name": "idx_following_feed_user_time", 1072 + "columns": [ 1073 + { 1074 + "expression": "user_id", 1075 + "isExpression": false, 1076 + "asc": true, 1077 + "nulls": "last" 1078 + }, 1079 + { 1080 + "expression": "created_at", 1081 + "isExpression": false, 1082 + "asc": false, 1083 + "nulls": "last" 1084 + } 1085 + ], 1086 + "isUnique": false, 1087 + "concurrently": false, 1088 + "method": "btree", 1089 + "with": {} 1090 + } 1091 + }, 1092 + "foreignKeys": { 1093 + "following_feed_items_activity_id_feed_activities_id_fk": { 1094 + "name": "following_feed_items_activity_id_feed_activities_id_fk", 1095 + "tableFrom": "following_feed_items", 1096 + "tableTo": "feed_activities", 1097 + "columnsFrom": ["activity_id"], 1098 + "columnsTo": ["id"], 1099 + "onDelete": "cascade", 1100 + "onUpdate": "no action" 1101 + } 1102 + }, 1103 + "compositePrimaryKeys": { 1104 + "following_feed_items_user_id_activity_id_pk": { 1105 + "name": "following_feed_items_user_id_activity_id_pk", 1106 + "columns": ["user_id", "activity_id"] 1107 + } 1108 + }, 1109 + "uniqueConstraints": {}, 1110 + "policies": {}, 1111 + "checkConstraints": {}, 1112 + "isRLSEnabled": false 1113 + }, 1114 + "public.notifications": { 1115 + "name": "notifications", 1116 + "schema": "", 1117 + "columns": { 1118 + "id": { 1119 + "name": "id", 1120 + "type": "uuid", 1121 + "primaryKey": true, 1122 + "notNull": true 1123 + }, 1124 + "recipient_user_id": { 1125 + "name": "recipient_user_id", 1126 + "type": "text", 1127 + "primaryKey": false, 1128 + "notNull": true 1129 + }, 1130 + "actor_user_id": { 1131 + "name": "actor_user_id", 1132 + "type": "text", 1133 + "primaryKey": false, 1134 + "notNull": true 1135 + }, 1136 + "type": { 1137 + "name": "type", 1138 + "type": "text", 1139 + "primaryKey": false, 1140 + "notNull": true 1141 + }, 1142 + "metadata": { 1143 + "name": "metadata", 1144 + "type": "jsonb", 1145 + "primaryKey": false, 1146 + "notNull": true 1147 + }, 1148 + "read": { 1149 + "name": "read", 1150 + "type": "boolean", 1151 + "primaryKey": false, 1152 + "notNull": true, 1153 + "default": false 1154 + }, 1155 + "created_at": { 1156 + "name": "created_at", 1157 + "type": "timestamp", 1158 + "primaryKey": false, 1159 + "notNull": true, 1160 + "default": "now()" 1161 + }, 1162 + "updated_at": { 1163 + "name": "updated_at", 1164 + "type": "timestamp", 1165 + "primaryKey": false, 1166 + "notNull": true, 1167 + "default": "now()" 1168 + } 1169 + }, 1170 + "indexes": { 1171 + "notifications_recipient_idx": { 1172 + "name": "notifications_recipient_idx", 1173 + "columns": [ 1174 + { 1175 + "expression": "recipient_user_id", 1176 + "isExpression": false, 1177 + "asc": true, 1178 + "nulls": "last" 1179 + } 1180 + ], 1181 + "isUnique": false, 1182 + "concurrently": false, 1183 + "method": "btree", 1184 + "with": {} 1185 + }, 1186 + "notifications_recipient_created_at_idx": { 1187 + "name": "notifications_recipient_created_at_idx", 1188 + "columns": [ 1189 + { 1190 + "expression": "recipient_user_id", 1191 + "isExpression": false, 1192 + "asc": true, 1193 + "nulls": "last" 1194 + }, 1195 + { 1196 + "expression": "created_at", 1197 + "isExpression": false, 1198 + "asc": false, 1199 + "nulls": "last" 1200 + } 1201 + ], 1202 + "isUnique": false, 1203 + "concurrently": false, 1204 + "method": "btree", 1205 + "with": {} 1206 + }, 1207 + "notifications_recipient_read_idx": { 1208 + "name": "notifications_recipient_read_idx", 1209 + "columns": [ 1210 + { 1211 + "expression": "recipient_user_id", 1212 + "isExpression": false, 1213 + "asc": true, 1214 + "nulls": "last" 1215 + }, 1216 + { 1217 + "expression": "read", 1218 + "isExpression": false, 1219 + "asc": true, 1220 + "nulls": "last" 1221 + } 1222 + ], 1223 + "isUnique": false, 1224 + "concurrently": false, 1225 + "method": "btree", 1226 + "with": {} 1227 + } 1228 + }, 1229 + "foreignKeys": {}, 1230 + "compositePrimaryKeys": {}, 1231 + "uniqueConstraints": {}, 1232 + "policies": {}, 1233 + "checkConstraints": {}, 1234 + "isRLSEnabled": false 1235 + }, 1236 + "public.sync_statuses": { 1237 + "name": "sync_statuses", 1238 + "schema": "", 1239 + "columns": { 1240 + "id": { 1241 + "name": "id", 1242 + "type": "uuid", 1243 + "primaryKey": true, 1244 + "notNull": true, 1245 + "default": "gen_random_uuid()" 1246 + }, 1247 + "curator_id": { 1248 + "name": "curator_id", 1249 + "type": "text", 1250 + "primaryKey": false, 1251 + "notNull": true 1252 + }, 1253 + "sync_state": { 1254 + "name": "sync_state", 1255 + "type": "text", 1256 + "primaryKey": false, 1257 + "notNull": true 1258 + }, 1259 + "last_synced_at": { 1260 + "name": "last_synced_at", 1261 + "type": "timestamp", 1262 + "primaryKey": false, 1263 + "notNull": false 1264 + }, 1265 + "last_sync_attempt_at": { 1266 + "name": "last_sync_attempt_at", 1267 + "type": "timestamp", 1268 + "primaryKey": false, 1269 + "notNull": false 1270 + }, 1271 + "sync_error_message": { 1272 + "name": "sync_error_message", 1273 + "type": "text", 1274 + "primaryKey": false, 1275 + "notNull": false 1276 + }, 1277 + "records_processed": { 1278 + "name": "records_processed", 1279 + "type": "integer", 1280 + "primaryKey": false, 1281 + "notNull": false 1282 + }, 1283 + "created_at": { 1284 + "name": "created_at", 1285 + "type": "timestamp", 1286 + "primaryKey": false, 1287 + "notNull": true, 1288 + "default": "now()" 1289 + }, 1290 + "updated_at": { 1291 + "name": "updated_at", 1292 + "type": "timestamp", 1293 + "primaryKey": false, 1294 + "notNull": true, 1295 + "default": "now()" 1296 + } 1297 + }, 1298 + "indexes": {}, 1299 + "foreignKeys": {}, 1300 + "compositePrimaryKeys": {}, 1301 + "uniqueConstraints": { 1302 + "sync_statuses_curator_id_unique": { 1303 + "name": "sync_statuses_curator_id_unique", 1304 + "nullsNotDistinct": false, 1305 + "columns": ["curator_id"] 1306 + } 1307 + }, 1308 + "policies": {}, 1309 + "checkConstraints": {}, 1310 + "isRLSEnabled": false 1311 + }, 1312 + "public.auth_session": { 1313 + "name": "auth_session", 1314 + "schema": "", 1315 + "columns": { 1316 + "key": { 1317 + "name": "key", 1318 + "type": "text", 1319 + "primaryKey": true, 1320 + "notNull": true 1321 + }, 1322 + "session": { 1323 + "name": "session", 1324 + "type": "text", 1325 + "primaryKey": false, 1326 + "notNull": true 1327 + } 1328 + }, 1329 + "indexes": {}, 1330 + "foreignKeys": {}, 1331 + "compositePrimaryKeys": {}, 1332 + "uniqueConstraints": {}, 1333 + "policies": {}, 1334 + "checkConstraints": {}, 1335 + "isRLSEnabled": false 1336 + }, 1337 + "public.auth_state": { 1338 + "name": "auth_state", 1339 + "schema": "", 1340 + "columns": { 1341 + "key": { 1342 + "name": "key", 1343 + "type": "text", 1344 + "primaryKey": true, 1345 + "notNull": true 1346 + }, 1347 + "state": { 1348 + "name": "state", 1349 + "type": "text", 1350 + "primaryKey": false, 1351 + "notNull": true 1352 + }, 1353 + "created_at": { 1354 + "name": "created_at", 1355 + "type": "timestamp", 1356 + "primaryKey": false, 1357 + "notNull": false, 1358 + "default": "now()" 1359 + } 1360 + }, 1361 + "indexes": {}, 1362 + "foreignKeys": {}, 1363 + "compositePrimaryKeys": {}, 1364 + "uniqueConstraints": {}, 1365 + "policies": {}, 1366 + "checkConstraints": {}, 1367 + "isRLSEnabled": false 1368 + }, 1369 + "public.auth_refresh_tokens": { 1370 + "name": "auth_refresh_tokens", 1371 + "schema": "", 1372 + "columns": { 1373 + "token_id": { 1374 + "name": "token_id", 1375 + "type": "text", 1376 + "primaryKey": true, 1377 + "notNull": true 1378 + }, 1379 + "user_did": { 1380 + "name": "user_did", 1381 + "type": "text", 1382 + "primaryKey": false, 1383 + "notNull": true 1384 + }, 1385 + "refresh_token": { 1386 + "name": "refresh_token", 1387 + "type": "text", 1388 + "primaryKey": false, 1389 + "notNull": true 1390 + }, 1391 + "issued_at": { 1392 + "name": "issued_at", 1393 + "type": "timestamp", 1394 + "primaryKey": false, 1395 + "notNull": true 1396 + }, 1397 + "expires_at": { 1398 + "name": "expires_at", 1399 + "type": "timestamp", 1400 + "primaryKey": false, 1401 + "notNull": true 1402 + }, 1403 + "revoked": { 1404 + "name": "revoked", 1405 + "type": "boolean", 1406 + "primaryKey": false, 1407 + "notNull": false, 1408 + "default": false 1409 + } 1410 + }, 1411 + "indexes": {}, 1412 + "foreignKeys": { 1413 + "auth_refresh_tokens_user_did_users_id_fk": { 1414 + "name": "auth_refresh_tokens_user_did_users_id_fk", 1415 + "tableFrom": "auth_refresh_tokens", 1416 + "tableTo": "users", 1417 + "columnsFrom": ["user_did"], 1418 + "columnsTo": ["id"], 1419 + "onDelete": "no action", 1420 + "onUpdate": "no action" 1421 + } 1422 + }, 1423 + "compositePrimaryKeys": {}, 1424 + "uniqueConstraints": {}, 1425 + "policies": {}, 1426 + "checkConstraints": {}, 1427 + "isRLSEnabled": false 1428 + }, 1429 + "public.follows": { 1430 + "name": "follows", 1431 + "schema": "", 1432 + "columns": { 1433 + "follower_id": { 1434 + "name": "follower_id", 1435 + "type": "text", 1436 + "primaryKey": false, 1437 + "notNull": true 1438 + }, 1439 + "target_id": { 1440 + "name": "target_id", 1441 + "type": "text", 1442 + "primaryKey": false, 1443 + "notNull": true 1444 + }, 1445 + "target_type": { 1446 + "name": "target_type", 1447 + "type": "text", 1448 + "primaryKey": false, 1449 + "notNull": true 1450 + }, 1451 + "published_record_id": { 1452 + "name": "published_record_id", 1453 + "type": "uuid", 1454 + "primaryKey": false, 1455 + "notNull": false 1456 + }, 1457 + "created_at": { 1458 + "name": "created_at", 1459 + "type": "timestamp", 1460 + "primaryKey": false, 1461 + "notNull": true, 1462 + "default": "now()" 1463 + } 1464 + }, 1465 + "indexes": { 1466 + "idx_follows_follower": { 1467 + "name": "idx_follows_follower", 1468 + "columns": [ 1469 + { 1470 + "expression": "follower_id", 1471 + "isExpression": false, 1472 + "asc": true, 1473 + "nulls": "last" 1474 + } 1475 + ], 1476 + "isUnique": false, 1477 + "concurrently": false, 1478 + "method": "btree", 1479 + "with": {} 1480 + }, 1481 + "idx_follows_target": { 1482 + "name": "idx_follows_target", 1483 + "columns": [ 1484 + { 1485 + "expression": "target_id", 1486 + "isExpression": false, 1487 + "asc": true, 1488 + "nulls": "last" 1489 + }, 1490 + { 1491 + "expression": "target_type", 1492 + "isExpression": false, 1493 + "asc": true, 1494 + "nulls": "last" 1495 + } 1496 + ], 1497 + "isUnique": false, 1498 + "concurrently": false, 1499 + "method": "btree", 1500 + "with": {} 1501 + } 1502 + }, 1503 + "foreignKeys": { 1504 + "follows_published_record_id_published_records_id_fk": { 1505 + "name": "follows_published_record_id_published_records_id_fk", 1506 + "tableFrom": "follows", 1507 + "tableTo": "published_records", 1508 + "columnsFrom": ["published_record_id"], 1509 + "columnsTo": ["id"], 1510 + "onDelete": "no action", 1511 + "onUpdate": "no action" 1512 + } 1513 + }, 1514 + "compositePrimaryKeys": { 1515 + "follows_follower_id_target_id_target_type_pk": { 1516 + "name": "follows_follower_id_target_id_target_type_pk", 1517 + "columns": ["follower_id", "target_id", "target_type"] 1518 + } 1519 + }, 1520 + "uniqueConstraints": {}, 1521 + "policies": {}, 1522 + "checkConstraints": {}, 1523 + "isRLSEnabled": false 1524 + }, 1525 + "public.users": { 1526 + "name": "users", 1527 + "schema": "", 1528 + "columns": { 1529 + "id": { 1530 + "name": "id", 1531 + "type": "text", 1532 + "primaryKey": true, 1533 + "notNull": true 1534 + }, 1535 + "handle": { 1536 + "name": "handle", 1537 + "type": "text", 1538 + "primaryKey": false, 1539 + "notNull": false 1540 + }, 1541 + "linked_at": { 1542 + "name": "linked_at", 1543 + "type": "timestamp", 1544 + "primaryKey": false, 1545 + "notNull": true 1546 + }, 1547 + "last_login_at": { 1548 + "name": "last_login_at", 1549 + "type": "timestamp", 1550 + "primaryKey": false, 1551 + "notNull": true 1552 + } 1553 + }, 1554 + "indexes": {}, 1555 + "foreignKeys": {}, 1556 + "compositePrimaryKeys": {}, 1557 + "uniqueConstraints": {}, 1558 + "policies": {}, 1559 + "checkConstraints": {}, 1560 + "isRLSEnabled": false 1561 + } 1562 + }, 1563 + "enums": {}, 1564 + "schemas": {}, 1565 + "sequences": {}, 1566 + "roles": {}, 1567 + "policies": {}, 1568 + "views": {}, 1569 + "_meta": { 1570 + "columns": {}, 1571 + "schemas": {}, 1572 + "tables": {} 1573 + } 1574 + }
+7
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 120 120 "when": 1770427721556, 121 121 "tag": "0016_ancient_cerise", 122 122 "breakpoints": true 123 + }, 124 + { 125 + "idx": 17, 126 + "version": "7", 127 + "when": 1770860659903, 128 + "tag": "0017_heavy_inertia", 129 + "breakpoints": true 123 130 } 124 131 ] 125 132 }
+4
src/shared/infrastructure/events/BullMQEventPublisher.ts
··· 65 65 return [QueueNames.NOTIFICATIONS]; 66 66 case EventNames.CARD_REMOVED_FROM_COLLECTION: 67 67 return [QueueNames.NOTIFICATIONS]; 68 + case EventNames.USER_FOLLOWED_TARGET: 69 + return [QueueNames.NOTIFICATIONS]; 70 + case EventNames.USER_UNFOLLOWED_TARGET: 71 + return [QueueNames.NOTIFICATIONS]; 68 72 default: 69 73 return [QueueNames.FEEDS]; 70 74 }
+2
src/shared/infrastructure/events/EventConfig.ts
··· 4 4 COLLECTION_CREATED: 'CollectionCreatedEvent', 5 5 CARD_REMOVED_FROM_LIBRARY: 'CardRemovedFromLibraryEvent', 6 6 CARD_REMOVED_FROM_COLLECTION: 'CardRemovedFromCollectionEvent', 7 + USER_FOLLOWED_TARGET: 'USER_FOLLOWED_TARGET', 8 + USER_UNFOLLOWED_TARGET: 'USER_UNFOLLOWED_TARGET', 7 9 } as const; 8 10 9 11 export type EventName = (typeof EventNames)[keyof typeof EventNames];
+82 -1
src/shared/infrastructure/events/EventMapper.ts
··· 4 4 import { CardRemovedFromLibraryEvent } from '../../../modules/cards/domain/events/CardRemovedFromLibraryEvent'; 5 5 import { CardRemovedFromCollectionEvent } from '../../../modules/cards/domain/events/CardRemovedFromCollectionEvent'; 6 6 import { CollectionCreatedEvent } from '../../../modules/cards/domain/events/CollectionCreatedEvent'; 7 + import { UserFollowedTargetEvent } from '../../../modules/user/domain/events/UserFollowedTargetEvent'; 8 + import { UserUnfollowedTargetEvent } from '../../../modules/user/domain/events/UserUnfollowedTargetEvent'; 7 9 import { CardId } from '../../../modules/cards/domain/value-objects/CardId'; 8 10 import { CollectionId } from '../../../modules/cards/domain/value-objects/CollectionId'; 9 11 import { CuratorId } from '../../../modules/cards/domain/value-objects/CuratorId'; 12 + import { DID } from '../../../modules/user/domain/value-objects/DID'; 13 + import { FollowTargetType } from '../../../modules/user/domain/value-objects/FollowTargetType'; 14 + import { UniqueEntityID } from '../../domain/UniqueEntityID'; 10 15 import { EventNames } from './EventConfig'; 11 16 12 17 export interface SerializedEvent { ··· 51 56 collectionName: string; 52 57 } 53 58 59 + export interface SerializedUserFollowedTargetEvent extends SerializedEvent { 60 + eventType: typeof EventNames.USER_FOLLOWED_TARGET; 61 + followId: string; 62 + followerId: string; 63 + targetId: string; 64 + targetType: 'USER' | 'COLLECTION'; 65 + createdAt: string; 66 + } 67 + 68 + export interface SerializedUserUnfollowedTargetEvent extends SerializedEvent { 69 + eventType: typeof EventNames.USER_UNFOLLOWED_TARGET; 70 + followId: string; 71 + followerId: string; 72 + targetId: string; 73 + targetType: 'USER' | 'COLLECTION'; 74 + } 75 + 54 76 export type SerializedEventUnion = 55 77 | SerializedCardAddedToLibraryEvent 56 78 | SerializedCardAddedToCollectionEvent 57 79 | SerializedCardRemovedFromLibraryEvent 58 80 | SerializedCardRemovedFromCollectionEvent 59 - | SerializedCollectionCreatedEvent; 81 + | SerializedCollectionCreatedEvent 82 + | SerializedUserFollowedTargetEvent 83 + | SerializedUserUnfollowedTargetEvent; 60 84 61 85 export class EventMapper { 62 86 static toSerialized(event: IDomainEvent): SerializedEventUnion { ··· 115 139 }; 116 140 } 117 141 142 + if (event instanceof UserFollowedTargetEvent) { 143 + return { 144 + eventType: EventNames.USER_FOLLOWED_TARGET, 145 + aggregateId: event.getAggregateId().toString(), 146 + dateTimeOccurred: event.dateTimeOccurred.toISOString(), 147 + followId: event.followId.toString(), 148 + followerId: event.followerId.value, 149 + targetId: event.targetId, 150 + targetType: event.targetType.value, 151 + createdAt: event.createdAt.toISOString(), 152 + }; 153 + } 154 + 155 + if (event instanceof UserUnfollowedTargetEvent) { 156 + return { 157 + eventType: EventNames.USER_UNFOLLOWED_TARGET, 158 + aggregateId: event.getAggregateId().toString(), 159 + dateTimeOccurred: event.dateTimeOccurred.toISOString(), 160 + followId: event.followId.toString(), 161 + followerId: event.followerId.value, 162 + targetId: event.targetId, 163 + targetType: event.targetType.value, 164 + }; 165 + } 166 + 118 167 throw new Error( 119 168 `Unknown event type for serialization: ${event.constructor.name}`, 120 169 ); ··· 189 238 collectionId, 190 239 authorId, 191 240 eventData.collectionName, 241 + dateTimeOccurred, 242 + ).unwrap(); 243 + } 244 + case EventNames.USER_FOLLOWED_TARGET: { 245 + const followId = new UniqueEntityID(eventData.followId); 246 + const followerId = DID.create(eventData.followerId).unwrap(); 247 + const targetType = FollowTargetType.create( 248 + eventData.targetType as any, 249 + ).unwrap(); 250 + const createdAt = new Date(eventData.createdAt); 251 + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); 252 + return UserFollowedTargetEvent.reconstruct( 253 + followId, 254 + followerId, 255 + eventData.targetId, 256 + targetType, 257 + createdAt, 258 + dateTimeOccurred, 259 + ).unwrap(); 260 + } 261 + case EventNames.USER_UNFOLLOWED_TARGET: { 262 + const followId = new UniqueEntityID(eventData.followId); 263 + const followerId = DID.create(eventData.followerId).unwrap(); 264 + const targetType = FollowTargetType.create( 265 + eventData.targetType as any, 266 + ).unwrap(); 267 + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); 268 + return UserUnfollowedTargetEvent.reconstruct( 269 + followId, 270 + followerId, 271 + eventData.targetId, 272 + targetType, 192 273 dateTimeOccurred, 193 274 ).unwrap(); 194 275 }
+2
src/shared/infrastructure/http/app.ts
··· 83 83 controllers.getUserProfileController, 84 84 controllers.refreshAccessTokenController, 85 85 controllers.generateExtensionTokensController, 86 + controllers.followTargetController, 87 + controllers.unfollowTargetController, 86 88 ); 87 89 88 90 createAtprotoRoutes(atprotoRouter, services.nodeOauthClient);
+10
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 43 43 import { GetUnreadNotificationCountController } from '../../../../modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController'; 44 44 import { MarkNotificationsAsReadController } from '../../../../modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController'; 45 45 import { MarkAllNotificationsAsReadController } from '../../../../modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController'; 46 + import { FollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/FollowTargetController'; 47 + import { UnfollowTargetController } from '../../../../modules/user/infrastructure/http/controllers/UnfollowTargetController'; 46 48 import { CookieService } from '../services/CookieService'; 47 49 48 50 export interface Controllers { ··· 55 57 getUserProfileController: GetUserProfileController; 56 58 refreshAccessTokenController: RefreshAccessTokenController; 57 59 generateExtensionTokensController: GenerateExtensionTokensController; 60 + followTargetController: FollowTargetController; 61 + unfollowTargetController: UnfollowTargetController; 58 62 // Card controllers 59 63 addUrlToLibraryController: AddUrlToLibraryController; 60 64 addCardToLibraryController: AddCardToLibraryController; ··· 128 132 ), 129 133 generateExtensionTokensController: new GenerateExtensionTokensController( 130 134 useCases.generateExtensionTokensUseCase, 135 + ), 136 + followTargetController: new FollowTargetController( 137 + useCases.followTargetUseCase, 138 + ), 139 + unfollowTargetController: new UnfollowTargetController( 140 + useCases.unfollowTargetUseCase, 131 141 ), 132 142 133 143 // Card controllers
+13
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 15 15 import { CachedBlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/CachedBlueskyProfileService'; 16 16 import { ATProtoCollectionPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher'; 17 17 import { ATProtoCardPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCardPublisher'; 18 + import { ATProtoFollowPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoFollowPublisher'; 18 19 import { FakeCollectionPublisher } from '../../../../modules/cards/tests/utils/FakeCollectionPublisher'; 19 20 import { FakeCardPublisher } from '../../../../modules/cards/tests/utils/FakeCardPublisher'; 21 + import { FakeFollowPublisher } from '../../../../modules/atproto/infrastructure/publishers/FakeFollowPublisher'; 20 22 import { CardLibraryService } from '../../../../modules/cards/domain/services/CardLibraryService'; 21 23 import { CardCollectionService } from '../../../../modules/cards/domain/services/CardCollectionService'; 22 24 import { AuthMiddleware } from '../middleware/AuthMiddleware'; ··· 26 28 import { AtpAppPasswordProcessor } from 'src/modules/atproto/infrastructure/services/AtpAppPasswordProcessor'; 27 29 import { ICollectionPublisher } from 'src/modules/cards/application/ports/ICollectionPublisher'; 28 30 import { ICardPublisher } from 'src/modules/cards/application/ports/ICardPublisher'; 31 + import { IFollowPublisher } from 'src/modules/user/application/ports/IFollowPublisher'; 29 32 import { IMetadataService } from 'src/modules/cards/domain/services/IMetadataService'; 30 33 import { BullMQEventSubscriber } from '../../events/BullMQEventSubscriber'; 31 34 import { BullMQEventPublisher } from '../../events/BullMQEventPublisher'; ··· 98 101 appPasswordProcessor: IAppPasswordProcessor; 99 102 collectionPublisher: ICollectionPublisher; 100 103 cardPublisher: ICardPublisher; 104 + followPublisher: IFollowPublisher; 101 105 authMiddleware: AuthMiddleware; 102 106 } 103 107 ··· 166 170 collections.card, 167 171 ); 168 172 173 + const followPublisher = useFakePublishers 174 + ? new FakeFollowPublisher() 175 + : new ATProtoFollowPublisher( 176 + sharedServices.atProtoAgentService, 177 + collections.follow, 178 + repositories.collectionRepository, 179 + ); 180 + 169 181 const authMiddleware = new AuthMiddleware( 170 182 sharedServices.tokenService, 171 183 sharedServices.cookieService, ··· 177 189 appPasswordProcessor, 178 190 collectionPublisher, 179 191 cardPublisher, 192 + followPublisher, 180 193 authMiddleware, 181 194 }; 182 195 }
+16
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 52 52 import { MarkAllNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase'; 53 53 import { CreateNotificationUseCase } from '../../../../modules/notifications/application/useCases/commands/CreateNotificationUseCase'; 54 54 import { SyncAccountDataUseCase } from '../../../../modules/sync/application/useCases/SyncAccountDataUseCase'; 55 + import { FollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/FollowTargetUseCase'; 56 + import { UnfollowTargetUseCase } from '../../../../modules/user/application/useCases/commands/UnfollowTargetUseCase'; 55 57 56 58 export interface WorkerUseCases { 57 59 addActivityToFeedUseCase: AddActivityToFeedUseCase; ··· 83 85 getProfileUseCase: GetProfileUseCase; 84 86 refreshAccessTokenUseCase: RefreshAccessTokenUseCase; 85 87 generateExtensionTokensUseCase: GenerateExtensionTokensUseCase; 88 + followTargetUseCase: FollowTargetUseCase; 89 + unfollowTargetUseCase: UnfollowTargetUseCase; 86 90 // Card use cases 87 91 addUrlToLibraryUseCase: AddUrlToLibraryUseCase; 88 92 addCardToLibraryUseCase: AddCardToLibraryUseCase; ··· 166 170 ), 167 171 generateExtensionTokensUseCase: new GenerateExtensionTokensUseCase( 168 172 services.tokenService, 173 + ), 174 + followTargetUseCase: new FollowTargetUseCase( 175 + repositories.followsRepository, 176 + repositories.userRepository, 177 + repositories.collectionRepository, 178 + services.followPublisher, 179 + services.eventPublisher, 180 + ), 181 + unfollowTargetUseCase: new UnfollowTargetUseCase( 182 + repositories.followsRepository, 183 + services.followPublisher, 184 + services.eventPublisher, 169 185 ), 170 186 171 187 // Card use cases
+13
src/shared/infrastructure/processes/NotificationWorkerProcess.ts
··· 9 9 import { CardRemovedFromLibraryEventHandler } from '../../../modules/notifications/application/eventHandlers/CardRemovedFromLibraryEventHandler'; 10 10 import { CollectionContributionEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionEventHandler'; 11 11 import { CollectionContributionCleanupEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionCleanupEventHandler'; 12 + import { UserFollowedTargetEventHandler } from '../../../modules/notifications/application/eventHandlers/UserFollowedTargetEventHandler'; 12 13 import { CardNotificationSaga } from '../../../modules/notifications/application/sagas/CardNotificationSaga'; 13 14 import { QueueNames } from '../events/QueueConfig'; 14 15 import { EventNames } from '../events/EventConfig'; ··· 70 71 repositories.collectionRepository, 71 72 ); 72 73 74 + // Follow notification handler 75 + const userFollowedTargetHandler = new UserFollowedTargetEventHandler( 76 + services.notificationService, 77 + repositories.userRepository, 78 + repositories.collectionRepository, 79 + ); 80 + 73 81 await subscriber.subscribe( 74 82 EventNames.CARD_ADDED_TO_LIBRARY, 75 83 cardAddedToLibraryHandler, ··· 95 103 await subscriber.subscribe( 96 104 EventNames.CARD_REMOVED_FROM_COLLECTION, 97 105 collectionContributionCleanupHandler, 106 + ); 107 + 108 + await subscriber.subscribe( 109 + EventNames.USER_FOLLOWED_TARGET, 110 + userFollowedTargetHandler, 98 111 ); 99 112 } 100 113 }
+11
src/types/src/api/requests.ts
··· 241 241 export interface MarkNotificationsAsReadRequest { 242 242 notificationIds: string[]; 243 243 } 244 + 245 + // Follow request types 246 + export interface FollowTargetRequest { 247 + targetId: string; // DID or Collection UUID 248 + targetType: 'USER' | 'COLLECTION'; 249 + } 250 + 251 + export interface UnfollowTargetRequest { 252 + targetId: string; 253 + targetType: 'USER' | 'COLLECTION'; 254 + }
+7
src/types/src/api/responses.ts
··· 335 335 USER_ADDED_YOUR_BSKY_POST = 'USER_ADDED_YOUR_BSKY_POST', 336 336 USER_ADDED_YOUR_COLLECTION = 'USER_ADDED_YOUR_COLLECTION', 337 337 USER_ADDED_TO_YOUR_COLLECTION = 'USER_ADDED_TO_YOUR_COLLECTION', 338 + USER_FOLLOWED_YOU = 'USER_FOLLOWED_YOU', 339 + USER_FOLLOWED_YOUR_COLLECTION = 'USER_FOLLOWED_YOUR_COLLECTION', 338 340 } 339 341 340 342 export interface NotificationItem { ··· 364 366 export interface MarkAllNotificationsAsReadResponse { 365 367 markedCount: number; 366 368 } 369 + 370 + // Follow response types 371 + export interface FollowTargetResponse { 372 + followId: string; 373 + }
+15
src/webapp/api-client/ApiClient.ts
··· 82 82 GetGemActivityFeedParams, 83 83 SearchCollectionsParams, 84 84 GetOpenCollectionsWithContributorParams, 85 + FollowTargetRequest, 86 + FollowTargetResponse, 85 87 } from '@semble/types'; 86 88 87 89 // Main API Client class using composition ··· 324 326 325 327 async logout(): Promise<{ success: boolean; message: string }> { 326 328 return this.userClient.logout(); 329 + } 330 + 331 + async followTarget( 332 + request: FollowTargetRequest, 333 + ): Promise<FollowTargetResponse> { 334 + return this.userClient.followTarget(request); 335 + } 336 + 337 + async unfollowTarget( 338 + targetId: string, 339 + targetType: 'USER' | 'COLLECTION', 340 + ): Promise<void> { 341 + return this.userClient.unfollowTarget(targetId, targetType); 327 342 } 328 343 329 344 // Feed operations - delegate to FeedClient
+22
src/webapp/api-client/clients/UserClient.ts
··· 10 10 CompleteOAuthSignInResponse, 11 11 RefreshAccessTokenResponse, 12 12 GenerateExtensionTokensResponse, 13 + FollowTargetRequest, 14 + FollowTargetResponse, 13 15 } from '@semble/types'; 14 16 15 17 export class UserClient extends BaseClient { ··· 75 77 return this.request<{ success: boolean; message: string }>( 76 78 'POST', 77 79 '/api/users/logout', 80 + ); 81 + } 82 + 83 + async followTarget( 84 + request: FollowTargetRequest, 85 + ): Promise<FollowTargetResponse> { 86 + return this.request<FollowTargetResponse>( 87 + 'POST', 88 + '/api/users/follows', 89 + request, 90 + ); 91 + } 92 + 93 + async unfollowTarget( 94 + targetId: string, 95 + targetType: 'USER' | 'COLLECTION', 96 + ): Promise<void> { 97 + return this.request<void>( 98 + 'DELETE', 99 + `/api/users/follows/${targetId}/${targetType}`, 78 100 ); 79 101 } 80 102 }