This repository has no description
0

Configure Feed

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

following domain and infrastructure layer

+426
+17
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 109 109 created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 110 110 updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 111 111 )`, 112 + 113 + // Follows table (no dependencies) 114 + sql`CREATE TABLE IF NOT EXISTS follows ( 115 + follower_id TEXT NOT NULL, 116 + target_id TEXT NOT NULL, 117 + target_type TEXT NOT NULL, 118 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 119 + PRIMARY KEY (follower_id, target_id, target_type) 120 + )`, 112 121 ]; 113 122 114 123 // Execute table creation queries in order ··· 238 247 `); 239 248 await db.execute(sql` 240 249 CREATE INDEX IF NOT EXISTS collection_cards_collection_id_idx ON collection_cards(collection_id); 250 + `); 251 + 252 + // Follows table indexes 253 + await db.execute(sql` 254 + CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id); 255 + `); 256 + await db.execute(sql` 257 + CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_id, target_type); 241 258 `); 242 259 }
+76
src/modules/user/domain/Follow.ts
··· 1 + import { AggregateRoot } from 'src/shared/domain/AggregateRoot'; 2 + import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; 3 + import { Guard, IGuardArgument } from 'src/shared/core/Guard'; 4 + import { err, ok, Result } from 'src/shared/core/Result'; 5 + import { DID } from './value-objects/DID'; 6 + import { FollowTargetType } from './value-objects/FollowTargetType'; 7 + 8 + export interface FollowProps { 9 + followerId: DID; 10 + targetId: string; 11 + targetType: FollowTargetType; 12 + createdAt: Date; 13 + } 14 + 15 + export class Follow extends AggregateRoot<FollowProps> { 16 + get followId(): UniqueEntityID { 17 + return this._id; 18 + } 19 + 20 + get followerId(): DID { 21 + return this.props.followerId; 22 + } 23 + 24 + get targetId(): string { 25 + return this.props.targetId; 26 + } 27 + 28 + get targetType(): FollowTargetType { 29 + return this.props.targetType; 30 + } 31 + 32 + get createdAt(): Date { 33 + return this.props.createdAt; 34 + } 35 + 36 + private constructor(props: FollowProps, id?: UniqueEntityID) { 37 + super(props, id); 38 + } 39 + 40 + public static create( 41 + props: FollowProps, 42 + id?: UniqueEntityID, 43 + ): Result<Follow> { 44 + const guardArgs: IGuardArgument[] = [ 45 + { argument: props.followerId, argumentName: 'followerId' }, 46 + { argument: props.targetId, argumentName: 'targetId' }, 47 + { argument: props.targetType, argumentName: 'targetType' }, 48 + { argument: props.createdAt, argumentName: 'createdAt' }, 49 + ]; 50 + 51 + const guardResult = Guard.againstNullOrUndefinedBulk(guardArgs); 52 + 53 + if (guardResult.isErr()) { 54 + return err(new Error(guardResult.error)); 55 + } 56 + 57 + const follow = new Follow(props, id); 58 + 59 + return ok(follow); 60 + } 61 + 62 + public static createNew( 63 + followerId: DID, 64 + targetId: string, 65 + targetType: FollowTargetType, 66 + ): Result<Follow> { 67 + const now = new Date(); 68 + 69 + return Follow.create({ 70 + followerId, 71 + targetId, 72 + targetType, 73 + createdAt: now, 74 + }); 75 + } 76 + }
+41
src/modules/user/domain/repositories/IFollowsRepository.ts
··· 1 + import { Result } from 'src/shared/core/Result'; 2 + import { Follow } from '../Follow'; 3 + import { FollowTargetType } from '../value-objects/FollowTargetType'; 4 + 5 + export interface IFollowsRepository { 6 + /** 7 + * Get all followers of a specific target (user or collection). 8 + * 9 + * @param targetId - The ID of the entity being followed (User DID or Collection UUID) 10 + * @param targetType - The type of entity being followed (USER or COLLECTION) 11 + * @returns Array of Follow records (can be empty if no followers) 12 + * 13 + * Example: 14 + * getFollowers('did:plc:alice123', FollowTargetType.USER) 15 + * → [Follow{ followerId: DID('did:plc:bob456'), targetId: 'did:plc:alice123', ... }] 16 + */ 17 + getFollowers( 18 + targetId: string, 19 + targetType: FollowTargetType, 20 + ): Promise<Result<Follow[]>>; 21 + 22 + /** 23 + * Get all followers of multiple collections (combined, deduplicated at use case level). 24 + * 25 + * @param collectionIds - Array of collection UUIDs 26 + * @returns Array of Follow records (can be empty) 27 + * 28 + * Example: 29 + * getFollowersOfCollections(['uuid-1', 'uuid-2']) 30 + * → [ 31 + * Follow{ followerId: DID('did:plc:bob456'), targetId: 'uuid-1', ... }, 32 + * Follow{ followerId: DID('did:plc:carol789'), targetId: 'uuid-2', ... } 33 + * ] 34 + * 35 + * Notes: 36 + * - Returns empty array if collectionIds is empty 37 + * - Results may include duplicates if a user follows multiple input collections 38 + * (deduplication happens at use case level) 39 + */ 40 + getFollowersOfCollections(collectionIds: string[]): Promise<Result<Follow[]>>; 41 + }
+37
src/modules/user/domain/value-objects/FollowTargetType.ts
··· 1 + import { ok, Result } from 'src/shared/core/Result'; 2 + import { ValueObject } from 'src/shared/domain/ValueObject'; 3 + 4 + export enum FollowTargetTypeEnum { 5 + USER = 'USER', 6 + COLLECTION = 'COLLECTION', 7 + } 8 + 9 + interface FollowTargetTypeProps { 10 + value: FollowTargetTypeEnum; 11 + } 12 + 13 + export class FollowTargetType extends ValueObject<FollowTargetTypeProps> { 14 + get value(): FollowTargetTypeEnum { 15 + return this.props.value; 16 + } 17 + 18 + private constructor(props: FollowTargetTypeProps) { 19 + super(props); 20 + } 21 + 22 + public static create(type: FollowTargetTypeEnum): Result<FollowTargetType> { 23 + return ok(new FollowTargetType({ value: type })); 24 + } 25 + 26 + public static get USER(): FollowTargetType { 27 + return new FollowTargetType({ value: FollowTargetTypeEnum.USER }); 28 + } 29 + 30 + public static get COLLECTION(): FollowTargetType { 31 + return new FollowTargetType({ value: FollowTargetTypeEnum.COLLECTION }); 32 + } 33 + 34 + public toString(): string { 35 + return this.props.value; 36 + } 37 + }
+2
src/modules/user/infrastructure/index.ts
··· 1 1 // Repositories 2 2 export * from './repositories/DrizzleUserRepository'; 3 + export * from './repositories/DrizzleFollowsRepository'; 3 4 4 5 // Services 5 6 export * from '../../atproto/infrastructure/services/AtProtoOAuthProcessor'; ··· 11 12 12 13 // Schema 13 14 export * from './repositories/schema/user.sql'; 15 + export * from './repositories/schema/follows.sql'; 14 16 export * from './repositories/schema/authToken.sql'; 15 17 export * from './repositories/schema/authState.sql'; 16 18 export * from './repositories/schema/authSession.sql';
+145
src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts
··· 1 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 + import { eq, and, sql } from 'drizzle-orm'; 3 + import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; 4 + import { Follow } from '../../domain/Follow'; 5 + import { DID } from '../../domain/value-objects/DID'; 6 + import { 7 + FollowTargetType, 8 + FollowTargetTypeEnum, 9 + } from '../../domain/value-objects/FollowTargetType'; 10 + import { follows } from './schema/follows.sql'; 11 + import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; 12 + import { err, ok, Result } from 'src/shared/core/Result'; 13 + 14 + export class DrizzleFollowsRepository implements IFollowsRepository { 15 + constructor(private db: PostgresJsDatabase) {} 16 + 17 + async getFollowers( 18 + targetId: string, 19 + targetType: FollowTargetType, 20 + ): Promise<Result<Follow[]>> { 21 + try { 22 + const results = await this.db 23 + .select() 24 + .from(follows) 25 + .where( 26 + and( 27 + eq(follows.targetId, targetId), 28 + eq(follows.targetType, targetType.value), 29 + ), 30 + ); 31 + 32 + const followEntities: Follow[] = []; 33 + 34 + for (const row of results) { 35 + const followerDIDResult = DID.create(row.followerId); 36 + if (followerDIDResult.isErr()) { 37 + console.error( 38 + `Invalid follower DID: ${row.followerId}`, 39 + followerDIDResult.error, 40 + ); 41 + continue; 42 + } 43 + 44 + const targetTypeResult = FollowTargetType.create( 45 + row.targetType as FollowTargetTypeEnum, 46 + ); 47 + if (targetTypeResult.isErr()) { 48 + console.error( 49 + `Invalid target type: ${row.targetType}`, 50 + targetTypeResult.error, 51 + ); 52 + continue; 53 + } 54 + 55 + const followResult = Follow.create( 56 + { 57 + followerId: followerDIDResult.value, 58 + targetId: row.targetId, 59 + targetType: targetTypeResult.value, 60 + createdAt: row.createdAt, 61 + }, 62 + new UniqueEntityID( 63 + `${row.followerId}:${row.targetId}:${row.targetType}`, 64 + ), 65 + ); 66 + 67 + if (followResult.isOk()) { 68 + followEntities.push(followResult.value); 69 + } else { 70 + console.error('Failed to create Follow entity:', followResult.error); 71 + } 72 + } 73 + 74 + return ok(followEntities); 75 + } catch (error: any) { 76 + return err(error); 77 + } 78 + } 79 + 80 + async getFollowersOfCollections( 81 + collectionIds: string[], 82 + ): Promise<Result<Follow[]>> { 83 + try { 84 + if (collectionIds.length === 0) { 85 + return ok([]); 86 + } 87 + 88 + const results = await this.db 89 + .select() 90 + .from(follows) 91 + .where( 92 + and( 93 + sql`${follows.targetId} = ANY(${collectionIds}::text[])`, 94 + eq(follows.targetType, FollowTargetTypeEnum.COLLECTION), 95 + ), 96 + ); 97 + 98 + const followEntities: Follow[] = []; 99 + 100 + for (const row of results) { 101 + const followerDIDResult = DID.create(row.followerId); 102 + if (followerDIDResult.isErr()) { 103 + console.error( 104 + `Invalid follower DID: ${row.followerId}`, 105 + followerDIDResult.error, 106 + ); 107 + continue; 108 + } 109 + 110 + const targetTypeResult = FollowTargetType.create( 111 + row.targetType as FollowTargetTypeEnum, 112 + ); 113 + if (targetTypeResult.isErr()) { 114 + console.error( 115 + `Invalid target type: ${row.targetType}`, 116 + targetTypeResult.error, 117 + ); 118 + continue; 119 + } 120 + 121 + const followResult = Follow.create( 122 + { 123 + followerId: followerDIDResult.value, 124 + targetId: row.targetId, 125 + targetType: targetTypeResult.value, 126 + createdAt: row.createdAt, 127 + }, 128 + new UniqueEntityID( 129 + `${row.followerId}:${row.targetId}:${row.targetType}`, 130 + ), 131 + ); 132 + 133 + if (followResult.isOk()) { 134 + followEntities.push(followResult.value); 135 + } else { 136 + console.error('Failed to create Follow entity:', followResult.error); 137 + } 138 + } 139 + 140 + return ok(followEntities); 141 + } catch (error: any) { 142 + return err(error); 143 + } 144 + } 145 + }
+24
src/modules/user/infrastructure/repositories/schema/follows.sql.ts
··· 1 + import { 2 + pgTable, 3 + text, 4 + timestamp, 5 + primaryKey, 6 + index, 7 + } from 'drizzle-orm/pg-core'; 8 + 9 + export const follows = pgTable( 10 + 'follows', 11 + { 12 + followerId: text('follower_id').notNull(), 13 + targetId: text('target_id').notNull(), 14 + targetType: text('target_type').notNull(), 15 + createdAt: timestamp('created_at').notNull().defaultNow(), 16 + }, 17 + (table) => ({ 18 + pk: primaryKey({ 19 + columns: [table.followerId, table.targetId, table.targetType], 20 + }), 21 + followerIdx: index('idx_follows_follower').on(table.followerId), 22 + targetIdx: index('idx_follows_target').on(table.targetId, table.targetType), 23 + }), 24 + );
+84
src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts
··· 1 + import { Result, ok, err } from 'src/shared/core/Result'; 2 + import { Follow } from '../../domain/Follow'; 3 + import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; 4 + import { 5 + FollowTargetType, 6 + FollowTargetTypeEnum, 7 + } from '../../domain/value-objects/FollowTargetType'; 8 + 9 + export class InMemoryFollowsRepository implements IFollowsRepository { 10 + private static instance: InMemoryFollowsRepository; 11 + private follows: Map<string, Follow> = new Map(); 12 + 13 + private constructor() {} 14 + 15 + public static getInstance(): InMemoryFollowsRepository { 16 + if (!InMemoryFollowsRepository.instance) { 17 + InMemoryFollowsRepository.instance = new InMemoryFollowsRepository(); 18 + } 19 + return InMemoryFollowsRepository.instance; 20 + } 21 + 22 + async getFollowers( 23 + targetId: string, 24 + targetType: FollowTargetType, 25 + ): Promise<Result<Follow[]>> { 26 + try { 27 + const followers: Follow[] = []; 28 + 29 + for (const follow of this.follows.values()) { 30 + if ( 31 + follow.targetId === targetId && 32 + follow.targetType.equals(targetType) 33 + ) { 34 + followers.push(follow); 35 + } 36 + } 37 + 38 + return ok(followers); 39 + } catch (error: any) { 40 + return err(error); 41 + } 42 + } 43 + 44 + async getFollowersOfCollections( 45 + collectionIds: string[], 46 + ): Promise<Result<Follow[]>> { 47 + try { 48 + if (collectionIds.length === 0) { 49 + return ok([]); 50 + } 51 + 52 + const followers: Follow[] = []; 53 + 54 + for (const follow of this.follows.values()) { 55 + if ( 56 + collectionIds.includes(follow.targetId) && 57 + follow.targetType.value === FollowTargetTypeEnum.COLLECTION 58 + ) { 59 + followers.push(follow); 60 + } 61 + } 62 + 63 + return ok(followers); 64 + } catch (error: any) { 65 + return err(error); 66 + } 67 + } 68 + 69 + // Helper method for testing 70 + async save(follow: Follow): Promise<Result<void>> { 71 + try { 72 + const key = `${follow.followerId.value}:${follow.targetId}:${follow.targetType.value}`; 73 + this.follows.set(key, follow); 74 + return ok(undefined); 75 + } catch (error: any) { 76 + return err(error); 77 + } 78 + } 79 + 80 + // Helper method for testing 81 + clear(): void { 82 + this.follows.clear(); 83 + } 84 + }