import { eq, desc, lt, count, sql, and, gte, inArray, notInArray, } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { IFeedRepository, FeedQueryOptions, PaginatedFeedResult, } from '../../domain/IFeedRepository'; import { FeedActivity } from '../../domain/FeedActivity'; import { ActivityId } from '../../domain/value-objects/ActivityId'; import { feedActivities } from './schema/feedActivity.sql'; import { followingFeedItems } from './schema/followingFeedItem.sql'; import { FeedActivityMapper, FeedActivityDTO, } from './mappers/FeedActivityMapper'; import { Result, ok, err } from '../../../../shared/core/Result'; import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../../cards/domain/value-objects/CardId'; import { ActivityTypeEnum } from '../../domain/value-objects/ActivityType'; import { ActivitySource } from '@semble/types'; import { KNOWN_BOT_DIDS } from '../../../../shared/constants/knownBots'; export class DrizzleFeedRepository implements IFeedRepository { constructor(private db: PostgresJsDatabase) {} async addActivity(activity: FeedActivity): Promise> { try { const dto = FeedActivityMapper.toPersistence(activity); await this.db.insert(feedActivities).values({ id: dto.id, actorId: dto.actorId, cardId: dto.cardId, connectionId: dto.connectionId, type: dto.type, metadata: dto.metadata, urlType: dto.urlType, source: dto.source, createdAt: dto.createdAt, }); return ok(undefined); } catch (error) { return err(error as Error); } } async getGlobalFeed( options: FeedQueryOptions, ): Promise> { try { const { page, limit, beforeActivityId } = options; const offset = (page - 1) * limit; // Build query conditionally let activitiesResult: Array<{ id: string; actorId: string; cardId: string | null; connectionId: string | null; type: string; metadata: any; urlType: string | null; source: string | null; createdAt: Date; }>; // Build where conditions const whereConditions = []; if (options.activityTypes && options.activityTypes.length > 0) { if (options.activityTypes.length === 1) { whereConditions.push( eq(feedActivities.type, options.activityTypes[0]!), ); } else { whereConditions.push( inArray(feedActivities.type, options.activityTypes), ); } } if (options.urlType) { whereConditions.push(eq(feedActivities.urlType, options.urlType)); } if (options.source) { if (options.source === ActivitySource.SEMBLE) { // Semble content has source IS NULL whereConditions.push(sql`${feedActivities.source} IS NULL`); } else { // Direct match for other sources (e.g., ActivitySource.MARGIN) whereConditions.push(eq(feedActivities.source, options.source)); } } // Filter out known bots by default (unless explicitly included) if (!options.includeKnownBots && KNOWN_BOT_DIDS.length > 0) { whereConditions.push( notInArray(feedActivities.actorId, KNOWN_BOT_DIDS), ); } if (beforeActivityId) { // Get the timestamp of the beforeActivityId const beforeActivity = await this.db .select({ createdAt: feedActivities.createdAt }) .from(feedActivities) .where(eq(feedActivities.id, beforeActivityId.getStringValue())) .limit(1); if (beforeActivity.length > 0) { const conditions = [ lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), ...whereConditions, ]; activitiesResult = await this.db .select() .from(feedActivities) .where(conditions.length > 1 ? and(...conditions) : conditions[0]) .orderBy(desc(feedActivities.createdAt), desc(feedActivities.id)) .limit(limit); } else { // If beforeActivityId doesn't exist, return empty result activitiesResult = []; } } else { // Regular pagination without cursor const query = this.db.select().from(feedActivities); if (whereConditions.length > 0) { query.where( whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0], ); } activitiesResult = await query .orderBy(desc(feedActivities.createdAt), desc(feedActivities.id)) .limit(limit) .offset(offset); } // Get total count with same filters const countQuery = this.db .select({ count: count() }) .from(feedActivities); if (whereConditions.length > 0) { countQuery.where( whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0], ); } const totalCountResult = await countQuery; const totalCount = totalCountResult[0]?.count || 0; // Map to domain objects const activities: FeedActivity[] = []; for (const activityData of activitiesResult) { const dto: FeedActivityDTO = { id: activityData.id, actorId: activityData.actorId, cardId: activityData.cardId || undefined, connectionId: activityData.connectionId || undefined, type: activityData.type, metadata: activityData.metadata as any, urlType: activityData.urlType || undefined, source: activityData.source || undefined, createdAt: activityData.createdAt, }; const domainResult = FeedActivityMapper.toDomain(dto); if (domainResult.isErr()) { return err(domainResult.error); } activities.push(domainResult.value); } // Determine if there are more activities const hasMore = offset + activities.length < totalCount; // Set next cursor if there are more activities let nextCursor: ActivityId | undefined; if (hasMore && activities.length > 0) { const lastActivity = activities[activities.length - 1]!; nextCursor = lastActivity.activityId; } return ok({ activities, totalCount, hasMore, nextCursor, }); } catch (error) { return err(error as Error); } } async getGemsFeed( collectionIds: CollectionId[], options: FeedQueryOptions, ): Promise> { try { const { page, limit, beforeActivityId } = options; const offset = (page - 1) * limit; const collectionIdStrings = collectionIds.map((id) => id.getStringValue(), ); // Handle empty collection IDs array if (collectionIdStrings.length === 0) { return ok({ activities: [], totalCount: 0, hasMore: false, nextCursor: undefined, }); } // Build query conditionally let activitiesResult: Array<{ id: string; actorId: string; cardId: string | null; connectionId: string | null; type: string; metadata: any; urlType: string | null; source: string | null; createdAt: Date; }>; // Build where conditions for gems feed const whereConditions = []; if (options.activityTypes && options.activityTypes.length > 0) { if (options.activityTypes.length === 1) { whereConditions.push( eq(feedActivities.type, options.activityTypes[0]!), ); } else { whereConditions.push( inArray(feedActivities.type, options.activityTypes), ); } } if (options.urlType) { whereConditions.push(eq(feedActivities.urlType, options.urlType)); } if (options.source) { if (options.source === ActivitySource.SEMBLE) { // Semble content has source IS NULL whereConditions.push(sql`${feedActivities.source} IS NULL`); } else { // Direct match for other sources (e.g., ActivitySource.MARGIN) whereConditions.push(eq(feedActivities.source, options.source)); } } // Filter out known bots by default (unless explicitly included) if (!options.includeKnownBots && KNOWN_BOT_DIDS.length > 0) { whereConditions.push( notInArray(feedActivities.actorId, KNOWN_BOT_DIDS), ); } // Create the JSON array condition using jsonb_array_elements_text const arrayLiteral = `{${collectionIdStrings.map((id) => `"${id}"`).join(',')}}`; const jsonArrayCondition = sql`EXISTS ( SELECT 1 FROM jsonb_array_elements_text(${feedActivities.metadata}->'collectionIds') AS collection_id WHERE collection_id = ANY(${arrayLiteral}::text[]) )`; if (beforeActivityId) { // Get the timestamp of the beforeActivityId const beforeActivity = await this.db .select({ createdAt: feedActivities.createdAt }) .from(feedActivities) .where(eq(feedActivities.id, beforeActivityId.getStringValue())) .limit(1); if (beforeActivity.length > 0) { const conditions = [ lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), jsonArrayCondition, ...whereConditions, ]; activitiesResult = await this.db .select() .from(feedActivities) .where(and(...conditions)) .orderBy(desc(feedActivities.createdAt), desc(feedActivities.id)) .limit(limit); } else { // If beforeActivityId doesn't exist, return empty result activitiesResult = []; } } else { // Regular pagination without cursor const conditions = [jsonArrayCondition, ...whereConditions]; activitiesResult = await this.db .select() .from(feedActivities) .where(conditions.length > 1 ? and(...conditions) : conditions[0]) .orderBy(desc(feedActivities.createdAt), desc(feedActivities.id)) .limit(limit) .offset(offset); } // Get total count with same filter const conditions = [jsonArrayCondition]; if (options.urlType) { conditions.push(eq(feedActivities.urlType, options.urlType)); } const totalCountResult = await this.db .select({ count: count() }) .from(feedActivities) .where(conditions.length > 1 ? and(...conditions) : conditions[0]); const totalCount = totalCountResult[0]?.count || 0; // Map to domain objects const activities: FeedActivity[] = []; for (const activityData of activitiesResult) { const dto: FeedActivityDTO = { id: activityData.id, actorId: activityData.actorId, cardId: activityData.cardId || undefined, connectionId: activityData.connectionId || undefined, type: activityData.type, metadata: activityData.metadata as any, urlType: activityData.urlType || undefined, source: activityData.source || undefined, createdAt: activityData.createdAt, }; const domainResult = FeedActivityMapper.toDomain(dto); if (domainResult.isErr()) { return err(domainResult.error); } activities.push(domainResult.value); } // Determine if there are more activities const hasMore = offset + activities.length < totalCount; // Set next cursor if there are more activities let nextCursor: ActivityId | undefined; if (hasMore && activities.length > 0) { const lastActivity = activities[activities.length - 1]!; nextCursor = lastActivity.activityId; } return ok({ activities, totalCount, hasMore, nextCursor, }); } catch (error) { return err(error as Error); } } async findById(activityId: ActivityId): Promise> { try { const activityResult = await this.db .select() .from(feedActivities) .where(eq(feedActivities.id, activityId.getStringValue())) .limit(1); if (activityResult.length === 0) { return ok(null); } const activityData = activityResult[0]!; const dto: FeedActivityDTO = { id: activityData.id, actorId: activityData.actorId, cardId: activityData.cardId || undefined, type: activityData.type, metadata: activityData.metadata as any, urlType: activityData.urlType || undefined, createdAt: activityData.createdAt, }; const domainResult = FeedActivityMapper.toDomain(dto); if (domainResult.isErr()) { return err(domainResult.error); } return ok(domainResult.value); } catch (error) { return err(error as Error); } } async findRecentCardCollectedActivity( actorId: CuratorId, cardId: CardId, withinMinutes: number, ): Promise> { try { const cutoffTime = new Date(Date.now() - withinMinutes * 60 * 1000); const result = await this.db .select() .from(feedActivities) .where( and( eq(feedActivities.actorId, actorId.value), eq(feedActivities.cardId, cardId.getStringValue()), eq(feedActivities.type, ActivityTypeEnum.CARD_COLLECTED), gte(feedActivities.createdAt, cutoffTime), ), ) .orderBy(desc(feedActivities.createdAt)) .limit(1); if (result.length === 0) { return ok(null); } const activityData = result[0]!; const dto: FeedActivityDTO = { id: activityData.id, actorId: activityData.actorId, cardId: activityData.cardId || undefined, type: activityData.type, metadata: activityData.metadata as any, urlType: activityData.urlType || undefined, createdAt: activityData.createdAt, }; const domainResult = FeedActivityMapper.toDomain(dto); if (domainResult.isErr()) { return err(domainResult.error); } return ok(domainResult.value); } catch (error) { return err(error as Error); } } async updateActivity(activity: FeedActivity): Promise> { try { const dto = FeedActivityMapper.toPersistence(activity); await this.db .update(feedActivities) .set({ metadata: dto.metadata, urlType: dto.urlType, }) .where(eq(feedActivities.id, dto.id)); return ok(undefined); } catch (error) { return err(error as Error); } } async fanOutActivityToFollowers( activityId: ActivityId, followerIds: string[], createdAt: Date, ): Promise> { try { if (followerIds.length === 0) { return ok(undefined); } const values = followerIds.map((userId) => ({ userId: userId, activityId: activityId.getStringValue(), createdAt: createdAt, })); await this.db .insert(followingFeedItems) .values(values) .onConflictDoNothing(); return ok(undefined); } catch (error) { return err(error as Error); } } async getFollowingFeed( userId: string, options: FeedQueryOptions, ): Promise> { try { const { page, limit, beforeActivityId } = options; const offset = (page - 1) * limit; // Build where conditions const whereConditions = [eq(followingFeedItems.userId, userId)]; if (options.activityTypes && options.activityTypes.length > 0) { if (options.activityTypes.length === 1) { whereConditions.push( eq(feedActivities.type, options.activityTypes[0]!), ); } else { whereConditions.push( inArray(feedActivities.type, options.activityTypes), ); } } if (options.urlType) { whereConditions.push(eq(feedActivities.urlType, options.urlType)); } if (options.source) { if (options.source === ActivitySource.SEMBLE) { whereConditions.push(sql`${feedActivities.source} IS NULL`); } else { whereConditions.push(eq(feedActivities.source, options.source)); } } // Filter out known bots by default (unless explicitly included) if (!options.includeKnownBots && KNOWN_BOT_DIDS.length > 0) { whereConditions.push( notInArray(feedActivities.actorId, KNOWN_BOT_DIDS), ); } // Cursor-based pagination if (beforeActivityId) { const beforeActivity = await this.db .select({ createdAt: followingFeedItems.createdAt }) .from(followingFeedItems) .where( and( eq(followingFeedItems.userId, userId), eq( followingFeedItems.activityId, beforeActivityId.getStringValue(), ), ), ) .limit(1); if (beforeActivity.length > 0) { whereConditions.push( lt(followingFeedItems.createdAt, beforeActivity[0]!.createdAt), ); } } // Main query with JOIN const activitiesResult = await this.db .select({ id: feedActivities.id, actorId: feedActivities.actorId, cardId: feedActivities.cardId, connectionId: feedActivities.connectionId, type: feedActivities.type, metadata: feedActivities.metadata, urlType: feedActivities.urlType, source: feedActivities.source, createdAt: followingFeedItems.createdAt, // Use denormalized timestamp }) .from(followingFeedItems) .innerJoin( feedActivities, eq(feedActivities.id, followingFeedItems.activityId), ) .where(and(...whereConditions)) .orderBy( desc(followingFeedItems.createdAt), desc(followingFeedItems.activityId), ) .limit(limit) .offset(offset); // Count total (with same filters) const totalCountResult = await this.db .select({ count: count() }) .from(followingFeedItems) .innerJoin( feedActivities, eq(feedActivities.id, followingFeedItems.activityId), ) .where(and(...whereConditions)); const totalCount = totalCountResult[0]?.count || 0; // Map to domain objects const activities: FeedActivity[] = []; for (const activityData of activitiesResult) { const dto: FeedActivityDTO = { id: activityData.id, actorId: activityData.actorId, cardId: activityData.cardId || undefined, connectionId: activityData.connectionId || undefined, type: activityData.type, metadata: activityData.metadata as any, urlType: activityData.urlType || undefined, source: activityData.source || undefined, createdAt: activityData.createdAt, }; const domainResult = FeedActivityMapper.toDomain(dto); if (domainResult.isErr()) { return err(domainResult.error); } activities.push(domainResult.value); } const hasMore = offset + activities.length < totalCount; const nextCursor = hasMore && activities.length > 0 ? activities[activities.length - 1]!.activityId : undefined; return ok({ activities, totalCount, hasMore, nextCursor, }); } catch (error) { return err(error as Error); } } }