diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index 3fc75662..39494b6f 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -109,6 +109,15 @@ export async function createTestSchema(db: PostgresJsDatabase) { created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() )`, + + // Follows table (no dependencies) + sql`CREATE TABLE IF NOT EXISTS follows ( + follower_id TEXT NOT NULL, + target_id TEXT NOT NULL, + target_type TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (follower_id, target_id, target_type) + )`, ]; // Execute table creation queries in order @@ -239,4 +248,12 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS collection_cards_collection_id_idx ON collection_cards(collection_id); `); + + // Follows table indexes + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_id, target_type); + `); } diff --git a/src/modules/user/domain/Follow.ts b/src/modules/user/domain/Follow.ts new file mode 100644 index 00000000..8db76bcf --- /dev/null +++ b/src/modules/user/domain/Follow.ts @@ -0,0 +1,76 @@ +import { AggregateRoot } from 'src/shared/domain/AggregateRoot'; +import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; +import { Guard, IGuardArgument } from 'src/shared/core/Guard'; +import { err, ok, Result } from 'src/shared/core/Result'; +import { DID } from './value-objects/DID'; +import { FollowTargetType } from './value-objects/FollowTargetType'; + +export interface FollowProps { + followerId: DID; + targetId: string; + targetType: FollowTargetType; + createdAt: Date; +} + +export class Follow extends AggregateRoot { + get followId(): UniqueEntityID { + return this._id; + } + + get followerId(): DID { + return this.props.followerId; + } + + get targetId(): string { + return this.props.targetId; + } + + get targetType(): FollowTargetType { + return this.props.targetType; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + private constructor(props: FollowProps, id?: UniqueEntityID) { + super(props, id); + } + + public static create( + props: FollowProps, + id?: UniqueEntityID, + ): Result { + const guardArgs: IGuardArgument[] = [ + { argument: props.followerId, argumentName: 'followerId' }, + { argument: props.targetId, argumentName: 'targetId' }, + { argument: props.targetType, argumentName: 'targetType' }, + { argument: props.createdAt, argumentName: 'createdAt' }, + ]; + + const guardResult = Guard.againstNullOrUndefinedBulk(guardArgs); + + if (guardResult.isErr()) { + return err(new Error(guardResult.error)); + } + + const follow = new Follow(props, id); + + return ok(follow); + } + + public static createNew( + followerId: DID, + targetId: string, + targetType: FollowTargetType, + ): Result { + const now = new Date(); + + return Follow.create({ + followerId, + targetId, + targetType, + createdAt: now, + }); + } +} diff --git a/src/modules/user/domain/repositories/IFollowsRepository.ts b/src/modules/user/domain/repositories/IFollowsRepository.ts new file mode 100644 index 00000000..f27af15d --- /dev/null +++ b/src/modules/user/domain/repositories/IFollowsRepository.ts @@ -0,0 +1,41 @@ +import { Result } from 'src/shared/core/Result'; +import { Follow } from '../Follow'; +import { FollowTargetType } from '../value-objects/FollowTargetType'; + +export interface IFollowsRepository { + /** + * Get all followers of a specific target (user or collection). + * + * @param targetId - The ID of the entity being followed (User DID or Collection UUID) + * @param targetType - The type of entity being followed (USER or COLLECTION) + * @returns Array of Follow records (can be empty if no followers) + * + * Example: + * getFollowers('did:plc:alice123', FollowTargetType.USER) + * → [Follow{ followerId: DID('did:plc:bob456'), targetId: 'did:plc:alice123', ... }] + */ + getFollowers( + targetId: string, + targetType: FollowTargetType, + ): Promise>; + + /** + * Get all followers of multiple collections (combined, deduplicated at use case level). + * + * @param collectionIds - Array of collection UUIDs + * @returns Array of Follow records (can be empty) + * + * Example: + * getFollowersOfCollections(['uuid-1', 'uuid-2']) + * → [ + * Follow{ followerId: DID('did:plc:bob456'), targetId: 'uuid-1', ... }, + * Follow{ followerId: DID('did:plc:carol789'), targetId: 'uuid-2', ... } + * ] + * + * Notes: + * - Returns empty array if collectionIds is empty + * - Results may include duplicates if a user follows multiple input collections + * (deduplication happens at use case level) + */ + getFollowersOfCollections(collectionIds: string[]): Promise>; +} diff --git a/src/modules/user/domain/value-objects/FollowTargetType.ts b/src/modules/user/domain/value-objects/FollowTargetType.ts new file mode 100644 index 00000000..437e4ce6 --- /dev/null +++ b/src/modules/user/domain/value-objects/FollowTargetType.ts @@ -0,0 +1,37 @@ +import { ok, Result } from 'src/shared/core/Result'; +import { ValueObject } from 'src/shared/domain/ValueObject'; + +export enum FollowTargetTypeEnum { + USER = 'USER', + COLLECTION = 'COLLECTION', +} + +interface FollowTargetTypeProps { + value: FollowTargetTypeEnum; +} + +export class FollowTargetType extends ValueObject { + get value(): FollowTargetTypeEnum { + return this.props.value; + } + + private constructor(props: FollowTargetTypeProps) { + super(props); + } + + public static create(type: FollowTargetTypeEnum): Result { + return ok(new FollowTargetType({ value: type })); + } + + public static get USER(): FollowTargetType { + return new FollowTargetType({ value: FollowTargetTypeEnum.USER }); + } + + public static get COLLECTION(): FollowTargetType { + return new FollowTargetType({ value: FollowTargetTypeEnum.COLLECTION }); + } + + public toString(): string { + return this.props.value; + } +} diff --git a/src/modules/user/infrastructure/index.ts b/src/modules/user/infrastructure/index.ts index e61ed400..9931f401 100644 --- a/src/modules/user/infrastructure/index.ts +++ b/src/modules/user/infrastructure/index.ts @@ -1,5 +1,6 @@ // Repositories export * from './repositories/DrizzleUserRepository'; +export * from './repositories/DrizzleFollowsRepository'; // Services export * from '../../atproto/infrastructure/services/AtProtoOAuthProcessor'; @@ -11,6 +12,7 @@ export * from './services/OAuthClientFactory'; // Schema export * from './repositories/schema/user.sql'; +export * from './repositories/schema/follows.sql'; export * from './repositories/schema/authToken.sql'; export * from './repositories/schema/authState.sql'; export * from './repositories/schema/authSession.sql'; diff --git a/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts new file mode 100644 index 00000000..52a3df08 --- /dev/null +++ b/src/modules/user/infrastructure/repositories/DrizzleFollowsRepository.ts @@ -0,0 +1,145 @@ +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { eq, and, sql } from 'drizzle-orm'; +import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; +import { Follow } from '../../domain/Follow'; +import { DID } from '../../domain/value-objects/DID'; +import { + FollowTargetType, + FollowTargetTypeEnum, +} from '../../domain/value-objects/FollowTargetType'; +import { follows } from './schema/follows.sql'; +import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; +import { err, ok, Result } from 'src/shared/core/Result'; + +export class DrizzleFollowsRepository implements IFollowsRepository { + constructor(private db: PostgresJsDatabase) {} + + async getFollowers( + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const results = await this.db + .select() + .from(follows) + .where( + and( + eq(follows.targetId, targetId), + eq(follows.targetType, targetType.value), + ), + ); + + const followEntities: Follow[] = []; + + for (const row of results) { + const followerDIDResult = DID.create(row.followerId); + if (followerDIDResult.isErr()) { + console.error( + `Invalid follower DID: ${row.followerId}`, + followerDIDResult.error, + ); + continue; + } + + const targetTypeResult = FollowTargetType.create( + row.targetType as FollowTargetTypeEnum, + ); + if (targetTypeResult.isErr()) { + console.error( + `Invalid target type: ${row.targetType}`, + targetTypeResult.error, + ); + continue; + } + + const followResult = Follow.create( + { + followerId: followerDIDResult.value, + targetId: row.targetId, + targetType: targetTypeResult.value, + createdAt: row.createdAt, + }, + new UniqueEntityID( + `${row.followerId}:${row.targetId}:${row.targetType}`, + ), + ); + + if (followResult.isOk()) { + followEntities.push(followResult.value); + } else { + console.error('Failed to create Follow entity:', followResult.error); + } + } + + return ok(followEntities); + } catch (error: any) { + return err(error); + } + } + + async getFollowersOfCollections( + collectionIds: string[], + ): Promise> { + try { + if (collectionIds.length === 0) { + return ok([]); + } + + const results = await this.db + .select() + .from(follows) + .where( + and( + sql`${follows.targetId} = ANY(${collectionIds}::text[])`, + eq(follows.targetType, FollowTargetTypeEnum.COLLECTION), + ), + ); + + const followEntities: Follow[] = []; + + for (const row of results) { + const followerDIDResult = DID.create(row.followerId); + if (followerDIDResult.isErr()) { + console.error( + `Invalid follower DID: ${row.followerId}`, + followerDIDResult.error, + ); + continue; + } + + const targetTypeResult = FollowTargetType.create( + row.targetType as FollowTargetTypeEnum, + ); + if (targetTypeResult.isErr()) { + console.error( + `Invalid target type: ${row.targetType}`, + targetTypeResult.error, + ); + continue; + } + + const followResult = Follow.create( + { + followerId: followerDIDResult.value, + targetId: row.targetId, + targetType: targetTypeResult.value, + createdAt: row.createdAt, + }, + new UniqueEntityID( + `${row.followerId}:${row.targetId}:${row.targetType}`, + ), + ); + + if (followResult.isOk()) { + followEntities.push(followResult.value); + } else { + console.error('Failed to create Follow entity:', followResult.error); + } + } + + return ok(followEntities); + } catch (error: any) { + return err(error); + } + } +} diff --git a/src/modules/user/infrastructure/repositories/schema/follows.sql.ts b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts new file mode 100644 index 00000000..8f387253 --- /dev/null +++ b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts @@ -0,0 +1,24 @@ +import { + pgTable, + text, + timestamp, + primaryKey, + index, +} from 'drizzle-orm/pg-core'; + +export const follows = pgTable( + 'follows', + { + followerId: text('follower_id').notNull(), + targetId: text('target_id').notNull(), + targetType: text('target_type').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ + columns: [table.followerId, table.targetId, table.targetType], + }), + followerIdx: index('idx_follows_follower').on(table.followerId), + targetIdx: index('idx_follows_target').on(table.targetId, table.targetType), + }), +); diff --git a/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts new file mode 100644 index 00000000..aeff16a7 --- /dev/null +++ b/src/modules/user/tests/infrastructure/InMemoryFollowsRepository.ts @@ -0,0 +1,84 @@ +import { Result, ok, err } from 'src/shared/core/Result'; +import { Follow } from '../../domain/Follow'; +import { IFollowsRepository } from '../../domain/repositories/IFollowsRepository'; +import { + FollowTargetType, + FollowTargetTypeEnum, +} from '../../domain/value-objects/FollowTargetType'; + +export class InMemoryFollowsRepository implements IFollowsRepository { + private static instance: InMemoryFollowsRepository; + private follows: Map = new Map(); + + private constructor() {} + + public static getInstance(): InMemoryFollowsRepository { + if (!InMemoryFollowsRepository.instance) { + InMemoryFollowsRepository.instance = new InMemoryFollowsRepository(); + } + return InMemoryFollowsRepository.instance; + } + + async getFollowers( + targetId: string, + targetType: FollowTargetType, + ): Promise> { + try { + const followers: Follow[] = []; + + for (const follow of this.follows.values()) { + if ( + follow.targetId === targetId && + follow.targetType.equals(targetType) + ) { + followers.push(follow); + } + } + + return ok(followers); + } catch (error: any) { + return err(error); + } + } + + async getFollowersOfCollections( + collectionIds: string[], + ): Promise> { + try { + if (collectionIds.length === 0) { + return ok([]); + } + + const followers: Follow[] = []; + + for (const follow of this.follows.values()) { + if ( + collectionIds.includes(follow.targetId) && + follow.targetType.value === FollowTargetTypeEnum.COLLECTION + ) { + followers.push(follow); + } + } + + return ok(followers); + } catch (error: any) { + return err(error); + } + } + + // Helper method for testing + async save(follow: Follow): Promise> { + try { + const key = `${follow.followerId.value}:${follow.targetId}:${follow.targetType.value}`; + this.follows.set(key, follow); + return ok(undefined); + } catch (error: any) { + return err(error); + } + } + + // Helper method for testing + clear(): void { + this.follows.clear(); + } +}