diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index 48e8ac89..ba615dca 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -78,6 +78,7 @@ export async function createTestSchema(db: PostgresJsDatabase) { id UUID PRIMARY KEY, actor_id TEXT NOT NULL, card_id TEXT, + connection_id TEXT, type TEXT NOT NULL, metadata JSONB NOT NULL, url_type TEXT, @@ -228,6 +229,9 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS feed_activities_card_id_idx ON feed_activities(card_id); `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS feed_activities_connection_id_idx ON feed_activities(connection_id); + `); await db.execute(sql` CREATE INDEX IF NOT EXISTS idx_feed_activities_actor_id ON feed_activities(actor_id); `); diff --git a/src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler.ts b/src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler.ts new file mode 100644 index 00000000..06bfc510 --- /dev/null +++ b/src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler.ts @@ -0,0 +1,30 @@ +import { ConnectionCreatedEvent } from '../../../cards/domain/events/ConnectionCreatedEvent'; +import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; +import { Result, ok, err } from '../../../../shared/core/Result'; +import { AddActivityToFeedUseCase } from '../useCases/commands/AddActivityToFeedUseCase'; +import { ActivityTypeEnum } from '../../../feeds/domain/value-objects/ActivityType'; + +export class ConnectionCreatedEventHandler + implements IEventHandler +{ + constructor(private addActivityToFeedUseCase: AddActivityToFeedUseCase) {} + + async handle(event: ConnectionCreatedEvent): Promise> { + const result = await this.addActivityToFeedUseCase.execute({ + type: ActivityTypeEnum.CONNECTION_CREATED, + actorId: event.curatorId.value, + connectionId: event.connectionId.getStringValue(), + createdAt: event.dateTimeOccurred, + }); + + if (result.isErr()) { + console.error( + 'Failed to add connection created activity to feed:', + result.error, + ); + return err(result.error); + } + + return ok(undefined); + } +} diff --git a/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts index ac646fc4..b7692d94 100644 --- a/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts @@ -5,6 +5,7 @@ import { AppError } from '../../../../../shared/core/AppError'; import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../../../cards/domain/value-objects/CardId'; import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; +import { ConnectionId } from '../../../../cards/domain/value-objects/ConnectionId'; import { ActivityTypeEnum } from '../../../domain/value-objects/ActivityType'; import { FeedService } from 'src/modules/feeds/domain/services/FeedService'; import { ICardRepository } from '../../../../cards/domain/ICardRepository'; @@ -25,7 +26,16 @@ export interface AddCardCollectedActivityDTO { createdAt?: Date; // Timestamp from earliest event (for historical data) } -export type AddActivityToFeedDTO = AddCardCollectedActivityDTO; +export interface AddConnectionCreatedActivityDTO { + type: ActivityTypeEnum.CONNECTION_CREATED; + actorId: string; + connectionId: string; + createdAt?: Date; // Timestamp from the connection creation event +} + +export type AddActivityToFeedDTO = + | AddCardCollectedActivityDTO + | AddConnectionCreatedActivityDTO; export interface AddActivityToFeedResponseDTO { activityId: string; @@ -63,6 +73,92 @@ export class AddActivityToFeedUseCase > > { try { + // Handle CONNECTION_CREATED activities + if (request.type === ActivityTypeEnum.CONNECTION_CREATED) { + // Validate and create CuratorId + const actorIdResult = CuratorId.create(request.actorId); + if (actorIdResult.isErr()) { + return err( + new ValidationError( + `Invalid actor ID: ${actorIdResult.error.message}`, + ), + ); + } + const actorId = actorIdResult.value; + + // Validate and create ConnectionId + const connectionIdResult = ConnectionId.createFromString( + request.connectionId, + ); + if (connectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid connection ID: ${connectionIdResult.error.message}`, + ), + ); + } + const connectionId = connectionIdResult.value; + + // Create connection activity (no source - connections are always Cosmik) + const activityResult = + await this.feedService.addConnectionCreatedActivity( + actorId, + connectionId, + undefined, // source + request.createdAt, + ); + + if (activityResult.isErr()) { + return err(new ValidationError(activityResult.error.message)); + } + + const activity = activityResult.value; + + // Fan out to user followers only (no collection followers for connections) + const targetTypeResult = FollowTargetType.create( + FollowTargetTypeEnum.USER, + ); + if (targetTypeResult.isErr()) { + console.error( + 'Failed to create FollowTargetType:', + targetTypeResult.error, + ); + return ok({ + activityId: activity.activityId.getStringValue(), + }); + } + + const userFollowersResult = await this.followsRepository.getFollowers( + actorId.value, + targetTypeResult.value, + ); + + const userFollowers = userFollowersResult.isOk() + ? userFollowersResult.value.map((f) => f.followerId.value) + : []; + + if (userFollowers.length > 0) { + const fanOutResult = + await this.feedRepository.fanOutActivityToFollowers( + activity.activityId, + userFollowers, + activity.createdAt, + ); + + if (fanOutResult.isErr()) { + console.error( + 'Fan-out failed (will retry on event retry):', + fanOutResult.error, + ); + } + } + + return ok({ + activityId: activity.activityId.getStringValue(), + }); + } + + // Handle CARD_COLLECTED activities // Validate and create CuratorId const actorIdResult = CuratorId.create(request.actorId); if (actorIdResult.isErr()) { diff --git a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts index be63b33f..b642b2d4 100644 --- a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts @@ -4,17 +4,30 @@ import { UseCaseError } from '../../../../../shared/core/UseCaseError'; import { AppError } from '../../../../../shared/core/AppError'; import { IFeedRepository } from '../../../domain/IFeedRepository'; import { ActivityId } from '../../../domain/value-objects/ActivityId'; +import { ActivityTypeEnum } from '../../../domain/value-objects/ActivityType'; import { IProfileService } from '../../../../cards/domain/services/IProfileService'; import { ICardQueryRepository, UrlCardView, } from '../../../../cards/domain/ICardQueryRepository'; import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; +import { IConnectionRepository } from 'src/modules/cards/domain/IConnectionRepository'; import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; +import { ConnectionId } from 'src/modules/cards/domain/value-objects/ConnectionId'; import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; -import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; +import { + GetGlobalFeedResponse, + FeedItem, + ActivitySource, + CardCollectedFeedItem, + ConnectionCreatedFeedItem, +} from '@semble/types'; import { CollectionAccessType } from '../../../../cards/domain/Collection'; import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; +import { + CardCollectedMetadata, + ConnectionCreatedMetadata, +} from '../../../domain/FeedActivity'; export interface GetFollowingFeedQuery { callingUserId: string; @@ -23,6 +36,7 @@ export interface GetFollowingFeedQuery { beforeActivityId?: string; // For cursor-based pagination urlType?: string; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } // Use the shared API type directly @@ -46,6 +60,7 @@ export class GetFollowingFeedUseCase private profileService: IProfileService, private cardQueryRepository: ICardQueryRepository, private collectionRepository: ICollectionRepository, + private connectionRepository: IConnectionRepository, ) {} async execute( @@ -79,6 +94,12 @@ export class GetFollowingFeedUseCase urlType = query.urlType as UrlType; } + // Parse activityTypes if provided + let activityTypes: ActivityTypeEnum[] | undefined; + if (query.activityTypes && query.activityTypes.length > 0) { + activityTypes = query.activityTypes as ActivityTypeEnum[]; + } + // Fetch activities from repository for the user's following feed const feedResult = await this.feedRepository.getFollowingFeed( query.callingUserId, @@ -88,6 +109,7 @@ export class GetFollowingFeedUseCase beforeActivityId, urlType, source: query.source, + activityTypes, }, ); @@ -125,7 +147,9 @@ export class GetFollowingFeedUseCase ...new Set( feed.activities .filter((activity) => activity.cardCollected) - .map((activity) => activity.metadata.cardId), + .map( + (activity) => (activity.metadata as CardCollectedMetadata).cardId, + ), ), ]; @@ -166,9 +190,14 @@ export class GetFollowingFeedUseCase feed.activities .filter( (activity) => - activity.cardCollected && activity.metadata.collectionIds, + activity.cardCollected && + (activity.metadata as CardCollectedMetadata).collectionIds, ) - .flatMap((activity) => activity.metadata.collectionIds || []), + .flatMap( + (activity) => + (activity.metadata as CardCollectedMetadata).collectionIds || + [], + ), ), ]; @@ -292,81 +321,247 @@ export class GetFollowingFeedUseCase }); }); - // Transform activities to FeedItem - const feedItems: FeedItem[] = []; - for (const activity of feed.activities) { - if (!activity.cardCollected) { - continue; // Skip non-card-collected activities + // Batch fetch connection data for CONNECTION_CREATED activities + const connectionActivities = feed.activities.filter( + (activity) => activity.connectionCreated, + ); + + let connectionDataMap = new Map< + string, + { + id: string; + type?: string; + note?: string; + createdAt: string; + updatedAt: string; + curatorId: string; + sourceUrl: string; + sourceUrlMetadata?: any; + targetUrl: string; + targetUrlMetadata?: any; } + >(); + let connectionCuratorProfiles = new Map(); - const actor = actorProfiles.get(activity.actorId.value); - const cardView = cardDataMap.get(activity.metadata.cardId); + if (connectionActivities.length > 0) { + // Get unique connection IDs + const connectionIds = [ + ...new Set( + connectionActivities.map( + (activity) => + (activity.metadata as ConnectionCreatedMetadata).connectionId, + ), + ), + ]; + + // Batch fetch connections + const connectionIdObjects: ConnectionId[] = []; + for (const connectionId of connectionIds) { + const connectionIdResult = + ConnectionId.createFromString(connectionId); + if (connectionIdResult.isOk()) { + connectionIdObjects.push(connectionIdResult.value); + } + } - if (!actor || !cardView) { - continue; // Skip if we can't hydrate required data + const connectionsResult = + await this.connectionRepository.findByIds(connectionIdObjects); + + if (connectionsResult.isErr()) { + return err(AppError.UnexpectedError.create(connectionsResult.error)); } - // Get card author - const cardAuthor = cardAuthorProfiles.get(cardView.authorId); - if (!cardAuthor) { - continue; // Skip if we can't get card author + const connections = connectionsResult.value; + + // Build connection data map + for (const connection of connections) { + const sourceUrl = connection.source.url?.value; + const targetUrl = connection.target.url?.value; + + // Only include connections where both source and target are URLs + if (sourceUrl && targetUrl) { + connectionDataMap.set(connection.connectionId.getStringValue(), { + id: connection.connectionId.getStringValue(), + type: connection.type?.value, + note: connection.note?.value, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + curatorId: connection.curatorId.value, + sourceUrl, + sourceUrlMetadata: connection.sourceUrlMetadata, + targetUrl, + targetUrlMetadata: connection.targetUrlMetadata, + }); + } } - // Transform UrlCardView to UrlCardDTO - const cardDTO = { - id: cardView.id, - type: 'URL' as const, - url: cardView.url, - uri: cardView.uri, - cardContent: { - url: cardView.cardContent.url, - title: cardView.cardContent.title, - description: cardView.cardContent.description, - author: cardView.cardContent.author, - publishedDate: cardView.cardContent.publishedDate?.toISOString(), - siteName: cardView.cardContent.siteName, - imageUrl: cardView.cardContent.imageUrl, - type: cardView.cardContent.type, - retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), - doi: cardView.cardContent.doi, - isbn: cardView.cardContent.isbn, - }, - libraryCount: cardView.libraryCount, - urlLibraryCount: cardView.urlLibraryCount, - urlInLibrary: cardView.urlInLibrary, - urlConnectionCount: cardView.urlConnectionCount, - urlIsConnected: cardView.urlIsConnected, - createdAt: cardView.createdAt.toISOString(), - updatedAt: cardView.updatedAt.toISOString(), - author: cardAuthor, - note: cardView.note, - }; + // Get unique curator IDs for connections + const connectionCuratorIds = [ + ...new Set( + Array.from(connectionDataMap.values()).map((c) => c.curatorId), + ), + ]; + + // Batch fetch curator profiles + const connectionCuratorProfilesResult = + await profileEnricher.buildProfileMap( + connectionCuratorIds, + query.callingUserId, + { + skipFailures: true, + mapToUser: false, + }, + ); - const collections = (activity.metadata.collectionIds || []) - .map((collectionId) => collectionDataMap.get(collectionId)) - .filter((collection) => !!collection) - .filter((collection) => - collection.cardIds.has(activity.metadata.cardId), - ) - .map((collection) => ({ - id: collection.id, - uri: collection.uri, - name: collection.name, - description: collection.description, - accessType: collection.accessType, - author: collection.author, - cardCount: collection.cardCount, - createdAt: collection.createdAt, - updatedAt: collection.updatedAt, - })); - - feedItems.push({ - id: activity.activityId.getStringValue(), - user: actor, - card: cardDTO, - createdAt: activity.createdAt, - collections, - }); + if (connectionCuratorProfilesResult.isErr()) { + return err( + AppError.UnexpectedError.create( + connectionCuratorProfilesResult.error, + ), + ); + } + + connectionCuratorProfiles = connectionCuratorProfilesResult.value; + } + + // Transform activities to FeedItem in chronological order + const feedItems: FeedItem[] = []; + for (const activity of feed.activities) { + const actor = actorProfiles.get(activity.actorId.value); + if (!actor) { + continue; // Skip if we can't get actor + } + + if (activity.cardCollected) { + // Handle CARD_COLLECTED activity + const metadata = activity.metadata as CardCollectedMetadata; + const cardView = cardDataMap.get(metadata.cardId); + + if (!cardView) { + continue; // Skip if we can't hydrate required data + } + + // Get card author + const cardAuthor = cardAuthorProfiles.get(cardView.authorId); + if (!cardAuthor) { + continue; // Skip if we can't get card author + } + + // Transform UrlCardView to UrlCardDTO + const cardDTO = { + id: cardView.id, + type: 'URL' as const, + url: cardView.url, + uri: cardView.uri, + cardContent: { + url: cardView.cardContent.url, + title: cardView.cardContent.title, + description: cardView.cardContent.description, + author: cardView.cardContent.author, + publishedDate: cardView.cardContent.publishedDate?.toISOString(), + siteName: cardView.cardContent.siteName, + imageUrl: cardView.cardContent.imageUrl, + type: cardView.cardContent.type, + retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), + doi: cardView.cardContent.doi, + isbn: cardView.cardContent.isbn, + }, + libraryCount: cardView.libraryCount, + urlLibraryCount: cardView.urlLibraryCount, + urlInLibrary: cardView.urlInLibrary, + urlConnectionCount: cardView.urlConnectionCount, + urlIsConnected: cardView.urlIsConnected, + createdAt: cardView.createdAt.toISOString(), + updatedAt: cardView.updatedAt.toISOString(), + author: cardAuthor, + note: cardView.note, + }; + + const collections = (metadata.collectionIds || []) + .map((collectionId) => collectionDataMap.get(collectionId)) + .filter((collection) => !!collection) + .filter((collection) => collection.cardIds.has(metadata.cardId)) + .map((collection) => ({ + id: collection.id, + uri: collection.uri, + name: collection.name, + description: collection.description, + accessType: collection.accessType, + author: collection.author, + cardCount: collection.cardCount, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + })); + + feedItems.push({ + id: activity.activityId.getStringValue(), + activityType: 'CARD_COLLECTED' as const, + user: actor, + card: cardDTO, + createdAt: activity.createdAt, + collections, + } as CardCollectedFeedItem); + } else if (activity.connectionCreated) { + // Handle CONNECTION_CREATED activity + const metadata = activity.metadata as ConnectionCreatedMetadata; + const connectionData = connectionDataMap.get(metadata.connectionId); + + if (!connectionData) { + continue; // Skip if we can't hydrate required data + } + + const curator = connectionCuratorProfiles.get( + connectionData.curatorId, + ); + if (!curator) { + continue; // Skip if we can't get curator profile + } + + // Build UrlView for source and target + // Extract metadata props to avoid the value object wrapper + const sourceUrlView = { + url: connectionData.sourceUrl, + metadata: connectionData.sourceUrlMetadata?.props || + connectionData.sourceUrlMetadata || { + url: connectionData.sourceUrl, + }, + urlLibraryCount: 0, // TODO: Fetch from DB if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + const targetUrlView = { + url: connectionData.targetUrl, + metadata: connectionData.targetUrlMetadata?.props || + connectionData.targetUrlMetadata || { + url: connectionData.targetUrl, + }, + urlLibraryCount: 0, // TODO: Fetch from DB if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + feedItems.push({ + id: activity.activityId.getStringValue(), + activityType: 'CONNECTION_CREATED' as const, + user: actor, + createdAt: activity.createdAt, + connection: { + connection: { + id: connectionData.id, + type: connectionData.type, + note: connectionData.note, + createdAt: connectionData.createdAt, + updatedAt: connectionData.updatedAt, + curator, + }, + source: sourceUrlView, + target: targetUrlView, + }, + } as ConnectionCreatedFeedItem); + } } return ok({ diff --git a/src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts index fb265cad..77282ca6 100644 --- a/src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts @@ -4,6 +4,7 @@ import { UseCaseError } from '../../../../../shared/core/UseCaseError'; import { AppError } from '../../../../../shared/core/AppError'; import { IFeedRepository } from '../../../domain/IFeedRepository'; import { ActivityId } from '../../../domain/value-objects/ActivityId'; +import { ActivityTypeEnum } from '../../../domain/value-objects/ActivityType'; import { IProfileService } from '../../../../cards/domain/services/IProfileService'; import { ICardQueryRepository, @@ -17,9 +18,15 @@ import { } from 'src/modules/cards/domain/ICollectionQueryRepository'; import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; -import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; +import { + GetGlobalFeedResponse, + FeedItem, + ActivitySource, + CardCollectedFeedItem, +} from '@semble/types'; import { CollectionAccessType } from '../../../../cards/domain/Collection'; import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; +import { CardCollectedMetadata } from '../../../domain/FeedActivity'; export interface GetGemActivityFeedQuery { callingUserId?: string; @@ -28,6 +35,7 @@ export interface GetGemActivityFeedQuery { beforeActivityId?: string; // For cursor-based pagination urlType?: string; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } // Use the shared API type directly @@ -138,6 +146,12 @@ export class GetGemActivityFeedUseCase urlType = query.urlType as UrlType; } + // Parse activityTypes if provided + let activityTypes: ActivityTypeEnum[] | undefined; + if (query.activityTypes && query.activityTypes.length > 0) { + activityTypes = query.activityTypes as ActivityTypeEnum[]; + } + // Fetch activities from repository using gems feed const feedResult = await this.feedRepository.getGemsFeed(collectionIds, { page, @@ -145,6 +159,7 @@ export class GetGemActivityFeedUseCase beforeActivityId, urlType, source: query.source, + activityTypes, }); if (feedResult.isErr()) { @@ -181,7 +196,9 @@ export class GetGemActivityFeedUseCase ...new Set( feed.activities .filter((activity) => activity.cardCollected) - .map((activity) => activity.metadata.cardId), + .map( + (activity) => (activity.metadata as CardCollectedMetadata).cardId, + ), ), ]; @@ -230,9 +247,14 @@ export class GetGemActivityFeedUseCase feed.activities .filter( (activity) => - activity.cardCollected && activity.metadata.collectionIds, + activity.cardCollected && + (activity.metadata as CardCollectedMetadata).collectionIds, ) - .flatMap((activity) => activity.metadata.collectionIds || []), + .flatMap( + (activity) => + (activity.metadata as CardCollectedMetadata).collectionIds || + [], + ), ), ]; @@ -362,7 +384,8 @@ export class GetGemActivityFeedUseCase } const actor = actorProfiles.get(activity.actorId.value); - const cardView = cardDataMap.get(activity.metadata.cardId); + const metadata = activity.metadata as CardCollectedMetadata; + const cardView = cardDataMap.get(metadata.cardId); if (!actor || !cardView) { continue; // Skip if we can't hydrate required data @@ -402,12 +425,10 @@ export class GetGemActivityFeedUseCase note: cardView.note, }; - const collections = (activity.metadata.collectionIds || []) + const collections = (metadata.collectionIds || []) .map((collectionId) => collectionDataMap.get(collectionId)) .filter((collection) => !!collection) - .filter((collection) => - collection.cardIds.has(activity.metadata.cardId), - ) + .filter((collection) => collection.cardIds.has(metadata.cardId)) .map((collection) => ({ id: collection.id, uri: collection.uri, @@ -427,11 +448,12 @@ export class GetGemActivityFeedUseCase feedItems.push({ id: activity.activityId.getStringValue(), + activityType: 'CARD_COLLECTED' as const, user: actor, card: cardDTO, createdAt: activity.createdAt, collections, - }); + } as CardCollectedFeedItem); } return ok({ diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index bf5a50da..f4fdb761 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -10,13 +10,26 @@ import { UrlCardView, } from '../../../../cards/domain/ICardQueryRepository'; import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; +import { IConnectionRepository } from 'src/modules/cards/domain/IConnectionRepository'; import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; +import { ConnectionId } from 'src/modules/cards/domain/value-objects/ConnectionId'; import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; -import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; +import { + GetGlobalFeedResponse, + FeedItem, + ActivitySource, + CardCollectedFeedItem, + ConnectionCreatedFeedItem, +} from '@semble/types'; import { CollectionAccessType } from '../../../../cards/domain/Collection'; import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; +import { + CardCollectedMetadata, + ConnectionCreatedMetadata, +} from '../../../domain/FeedActivity'; +import { ActivityTypeEnum } from 'src/modules/feeds/domain/value-objects/ActivityType'; export interface GetGlobalFeedQuery { callingUserId?: string; @@ -25,6 +38,7 @@ export interface GetGlobalFeedQuery { beforeActivityId?: string; // For cursor-based pagination urlType?: string; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } // Use the shared API type directly @@ -48,6 +62,7 @@ export class GetGlobalFeedUseCase private profileService: IProfileService, private cardQueryRepository: ICardQueryRepository, private collectionRepository: ICollectionRepository, + private connectionRepository: IConnectionRepository, private followsRepository: IFollowsRepository, ) {} @@ -82,6 +97,12 @@ export class GetGlobalFeedUseCase urlType = query.urlType as UrlType; } + // Parse activityTypes if provided + let activityTypes: ActivityTypeEnum[] | undefined; + if (query.activityTypes && query.activityTypes.length > 0) { + activityTypes = query.activityTypes as ActivityTypeEnum[]; + } + // Fetch activities from repository const feedResult = await this.feedRepository.getGlobalFeed({ page, @@ -89,6 +110,7 @@ export class GetGlobalFeedUseCase beforeActivityId, urlType, source: query.source, + activityTypes, }); if (feedResult.isErr()) { @@ -125,7 +147,9 @@ export class GetGlobalFeedUseCase ...new Set( feed.activities .filter((activity) => activity.cardCollected) - .map((activity) => activity.metadata.cardId), + .map( + (activity) => (activity.metadata as CardCollectedMetadata).cardId, + ), ), ]; @@ -166,9 +190,14 @@ export class GetGlobalFeedUseCase feed.activities .filter( (activity) => - activity.cardCollected && activity.metadata.collectionIds, + activity.cardCollected && + (activity.metadata as CardCollectedMetadata).collectionIds, ) - .flatMap((activity) => activity.metadata.collectionIds || []), + .flatMap( + (activity) => + (activity.metadata as CardCollectedMetadata).collectionIds || + [], + ), ), ]; @@ -307,90 +336,256 @@ export class GetGlobalFeedUseCase } } - // Transform activities to FeedItem - const feedItems: FeedItem[] = []; - for (const activity of feed.activities) { - if (!activity.cardCollected) { - continue; // Skip non-card-collected activities + // Batch fetch connection data for CONNECTION_CREATED activities + const connectionActivities = feed.activities.filter( + (activity) => activity.connectionCreated, + ); + + let connectionDataMap = new Map< + string, + { + id: string; + type?: string; + note?: string; + createdAt: string; + updatedAt: string; + curatorId: string; + sourceUrl: string; + sourceUrlMetadata?: any; + targetUrl: string; + targetUrlMetadata?: any; } + >(); + let connectionCuratorProfiles = new Map(); - const actor = actorProfiles.get(activity.actorId.value); - const cardView = cardDataMap.get(activity.metadata.cardId); + if (connectionActivities.length > 0) { + // Get unique connection IDs + const connectionIds = [ + ...new Set( + connectionActivities.map( + (activity) => + (activity.metadata as ConnectionCreatedMetadata).connectionId, + ), + ), + ]; + + // Batch fetch connections + const connectionIdObjects: ConnectionId[] = []; + for (const connectionId of connectionIds) { + const connectionIdResult = + ConnectionId.createFromString(connectionId); + if (connectionIdResult.isOk()) { + connectionIdObjects.push(connectionIdResult.value); + } + } - if (!actor || !cardView) { - continue; // Skip if we can't hydrate required data + const connectionsResult = + await this.connectionRepository.findByIds(connectionIdObjects); + + if (connectionsResult.isErr()) { + return err(AppError.UnexpectedError.create(connectionsResult.error)); } - // Get card author - const cardAuthor = cardAuthorProfiles.get(cardView.authorId); - if (!cardAuthor) { - continue; // Skip if we can't get card author + const connections = connectionsResult.value; + + // Build connection data map + for (const connection of connections) { + const sourceUrl = connection.source.url?.value; + const targetUrl = connection.target.url?.value; + + // Only include connections where both source and target are URLs + if (sourceUrl && targetUrl) { + connectionDataMap.set(connection.connectionId.getStringValue(), { + id: connection.connectionId.getStringValue(), + type: connection.type?.value, + note: connection.note?.value, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + curatorId: connection.curatorId.value, + sourceUrl, + sourceUrlMetadata: connection.sourceUrlMetadata, + targetUrl, + targetUrlMetadata: connection.targetUrlMetadata, + }); + } } - // Transform UrlCardView to UrlCardDTO - const cardDTO = { - id: cardView.id, - type: 'URL' as const, - url: cardView.url, - uri: cardView.uri, - cardContent: { - url: cardView.cardContent.url, - title: cardView.cardContent.title, - description: cardView.cardContent.description, - author: cardView.cardContent.author, - publishedDate: cardView.cardContent.publishedDate?.toISOString(), - siteName: cardView.cardContent.siteName, - imageUrl: cardView.cardContent.imageUrl, - type: cardView.cardContent.type, - retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), - doi: cardView.cardContent.doi, - isbn: cardView.cardContent.isbn, - }, - libraryCount: cardView.libraryCount, - urlLibraryCount: cardView.urlLibraryCount, - urlInLibrary: cardView.urlInLibrary, - urlConnectionCount: cardView.urlConnectionCount, - urlIsConnected: cardView.urlIsConnected, - createdAt: cardView.createdAt.toISOString(), - updatedAt: cardView.updatedAt.toISOString(), - author: cardAuthor, - note: cardView.note, - }; + // Get unique curator IDs for connections + const connectionCuratorIds = [ + ...new Set( + Array.from(connectionDataMap.values()).map((c) => c.curatorId), + ), + ]; + + // Batch fetch curator profiles + const connectionCuratorProfilesResult = + await profileEnricher.buildProfileMap( + connectionCuratorIds, + query.callingUserId, + { + skipFailures: true, + mapToUser: false, + }, + ); - const collections = (activity.metadata.collectionIds || []) - .map((collectionId) => { - const collection = collectionDataMap.get(collectionId); - if (!collection) return null; - - return { - collection, - collectionId, - }; - }) - .filter((item) => !!item) - .filter((item) => - item.collection.cardIds.has(activity.metadata.cardId), - ) - .map((item) => ({ - id: item.collection.id, - uri: item.collection.uri, - name: item.collection.name, - description: item.collection.description, - accessType: item.collection.accessType, - author: item.collection.author, - cardCount: item.collection.cardCount, - createdAt: item.collection.createdAt, - updatedAt: item.collection.updatedAt, - isFollowing: collectionFollowStatusMap.get(item.collectionId), - })); - - feedItems.push({ - id: activity.activityId.getStringValue(), - user: actor, - card: cardDTO, - createdAt: activity.createdAt, - collections, - }); + if (connectionCuratorProfilesResult.isErr()) { + return err( + AppError.UnexpectedError.create( + connectionCuratorProfilesResult.error, + ), + ); + } + + connectionCuratorProfiles = connectionCuratorProfilesResult.value; + } + + // Transform activities to FeedItem in chronological order + const feedItems: FeedItem[] = []; + for (const activity of feed.activities) { + const actor = actorProfiles.get(activity.actorId.value); + if (!actor) { + continue; // Skip if we can't get actor + } + + if (activity.cardCollected) { + // Handle CARD_COLLECTED activity + const metadata = activity.metadata as CardCollectedMetadata; + const cardView = cardDataMap.get(metadata.cardId); + + if (!cardView) { + continue; // Skip if we can't hydrate required data + } + + // Get card author + const cardAuthor = cardAuthorProfiles.get(cardView.authorId); + if (!cardAuthor) { + continue; // Skip if we can't get card author + } + + // Transform UrlCardView to UrlCardDTO + const cardDTO = { + id: cardView.id, + type: 'URL' as const, + url: cardView.url, + uri: cardView.uri, + cardContent: { + url: cardView.cardContent.url, + title: cardView.cardContent.title, + description: cardView.cardContent.description, + author: cardView.cardContent.author, + publishedDate: cardView.cardContent.publishedDate?.toISOString(), + siteName: cardView.cardContent.siteName, + imageUrl: cardView.cardContent.imageUrl, + type: cardView.cardContent.type, + retrievedAt: cardView.cardContent.retrievedAt?.toISOString(), + doi: cardView.cardContent.doi, + isbn: cardView.cardContent.isbn, + }, + libraryCount: cardView.libraryCount, + urlLibraryCount: cardView.urlLibraryCount, + urlInLibrary: cardView.urlInLibrary, + urlConnectionCount: cardView.urlConnectionCount, + urlIsConnected: cardView.urlIsConnected, + createdAt: cardView.createdAt.toISOString(), + updatedAt: cardView.updatedAt.toISOString(), + author: cardAuthor, + note: cardView.note, + }; + + const collections = (metadata.collectionIds || []) + .map((collectionId) => { + const collection = collectionDataMap.get(collectionId); + if (!collection) return null; + + return { + collection, + collectionId, + }; + }) + .filter((item) => !!item) + .filter((item) => item.collection.cardIds.has(metadata.cardId)) + .map((item) => ({ + id: item.collection.id, + uri: item.collection.uri, + name: item.collection.name, + description: item.collection.description, + accessType: item.collection.accessType, + author: item.collection.author, + cardCount: item.collection.cardCount, + createdAt: item.collection.createdAt, + updatedAt: item.collection.updatedAt, + isFollowing: collectionFollowStatusMap.get(item.collectionId), + })); + + feedItems.push({ + id: activity.activityId.getStringValue(), + activityType: ActivityTypeEnum.CARD_COLLECTED, + user: actor, + card: cardDTO, + createdAt: activity.createdAt, + collections, + } as CardCollectedFeedItem); + } else if (activity.connectionCreated) { + // Handle CONNECTION_CREATED activity + const metadata = activity.metadata as ConnectionCreatedMetadata; + const connectionData = connectionDataMap.get(metadata.connectionId); + + if (!connectionData) { + continue; // Skip if we can't hydrate required data + } + + const curator = connectionCuratorProfiles.get( + connectionData.curatorId, + ); + if (!curator) { + continue; // Skip if we can't get curator profile + } + + // Build UrlView for source and target + // Extract metadata props to avoid the value object wrapper + const sourceUrlView = { + url: connectionData.sourceUrl, + metadata: connectionData.sourceUrlMetadata?.props || + connectionData.sourceUrlMetadata || { + url: connectionData.sourceUrl, + }, + urlLibraryCount: 0, // TODO: Fetch from DB if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + const targetUrlView = { + url: connectionData.targetUrl, + metadata: connectionData.targetUrlMetadata?.props || + connectionData.targetUrlMetadata || { + url: connectionData.targetUrl, + }, + urlLibraryCount: 0, // TODO: Fetch from DB if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + feedItems.push({ + id: activity.activityId.getStringValue(), + activityType: 'CONNECTION_CREATED' as const, + user: actor, + createdAt: activity.createdAt, + connection: { + connection: { + id: connectionData.id, + type: connectionData.type, + note: connectionData.note, + createdAt: connectionData.createdAt, + updatedAt: connectionData.updatedAt, + curator, + }, + source: sourceUrlView, + target: targetUrlView, + }, + } as ConnectionCreatedFeedItem); + } } return ok({ diff --git a/src/modules/feeds/domain/FeedActivity.ts b/src/modules/feeds/domain/FeedActivity.ts index 4e7ee61f..a73b0f38 100644 --- a/src/modules/feeds/domain/FeedActivity.ts +++ b/src/modules/feeds/domain/FeedActivity.ts @@ -6,6 +6,7 @@ import { ActivityType, ActivityTypeEnum } from './value-objects/ActivityType'; import { CuratorId } from '../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../cards/domain/value-objects/CardId'; import { CollectionId } from '../../cards/domain/value-objects/CollectionId'; +import { ConnectionId } from '../../cards/domain/value-objects/ConnectionId'; import { UrlType } from '../../cards/domain/value-objects/UrlType'; export class ActivityValidationError extends Error { @@ -20,7 +21,13 @@ export interface CardCollectedMetadata { collectionIds?: string[]; } -export type ActivityMetadata = CardCollectedMetadata; +export interface ConnectionCreatedMetadata { + connectionId: string; +} + +export type ActivityMetadata = + | CardCollectedMetadata + | ConnectionCreatedMetadata; interface ActivityProps { actorId: CuratorId; // The user who performed the activity @@ -65,6 +72,10 @@ export class FeedActivity extends Entity { return this.props.type.value === ActivityTypeEnum.CARD_COLLECTED; } + get connectionCreated(): boolean { + return this.props.type.value === ActivityTypeEnum.CONNECTION_CREATED; + } + // Helper method to merge collections for deduplication public mergeCollections(newCollectionIds: CollectionId[]): void { if (!this.cardCollected) return; @@ -115,4 +126,36 @@ export class FeedActivity extends Entity { return ok(new FeedActivity(props, id)); } + + public static createConnectionCreated( + actorId: CuratorId, + connectionId: ConnectionId, + source?: string, + createdAt?: Date, + id?: UniqueEntityID, + ): Result { + if (!connectionId) { + return err(new ActivityValidationError('Connection ID is required')); + } + + const typeResult = ActivityType.connectionCreated(); + if (typeResult.isErr()) { + return err(new ActivityValidationError(typeResult.error.message)); + } + + const metadata: ConnectionCreatedMetadata = { + connectionId: connectionId.getStringValue(), + }; + + const props: ActivityProps = { + actorId, + type: typeResult.value, + metadata, + urlType: undefined, // No urlType for connection activities + source, + createdAt: createdAt || new Date(), + }; + + return ok(new FeedActivity(props, id)); + } } diff --git a/src/modules/feeds/domain/IFeedRepository.ts b/src/modules/feeds/domain/IFeedRepository.ts index fb09f8b3..0587bf1f 100644 --- a/src/modules/feeds/domain/IFeedRepository.ts +++ b/src/modules/feeds/domain/IFeedRepository.ts @@ -2,6 +2,7 @@ import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionI import { Result } from '../../../shared/core/Result'; import { FeedActivity } from './FeedActivity'; import { ActivityId } from './value-objects/ActivityId'; +import { ActivityTypeEnum } from './value-objects/ActivityType'; import { UrlType } from '../../cards/domain/value-objects/UrlType'; import { ActivitySource } from '@semble/types'; @@ -11,6 +12,7 @@ export interface FeedQueryOptions { beforeActivityId?: ActivityId; // For cursor-based pagination urlType?: UrlType; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: ActivityTypeEnum[]; // Filter by activity types } export interface PaginatedFeedResult { diff --git a/src/modules/feeds/domain/services/FeedService.ts b/src/modules/feeds/domain/services/FeedService.ts index f7f7d964..cffdbaa6 100644 --- a/src/modules/feeds/domain/services/FeedService.ts +++ b/src/modules/feeds/domain/services/FeedService.ts @@ -5,6 +5,7 @@ import { IFeedRepository } from '../IFeedRepository'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../../cards/domain/value-objects/CardId'; import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; +import { ConnectionId } from '../../../cards/domain/value-objects/ConnectionId'; import { UrlType } from '../../../cards/domain/value-objects/UrlType'; import { IDistributedLockService } from '../../../../shared/infrastructure/locking/IDistributedLockService'; @@ -114,4 +115,44 @@ export class FeedService implements DomainService { ); } } + + async addConnectionCreatedActivity( + actorId: CuratorId, + connectionId: ConnectionId, + source?: string, + createdAt?: Date, + ): Promise> { + try { + // No deduplication needed for connections - each creation is a separate activity + const activityResult = FeedActivity.createConnectionCreated( + actorId, + connectionId, + source, + createdAt, + ); + + if (activityResult.isErr()) { + return err(new FeedServiceError(activityResult.error.message)); + } + + const activity = activityResult.value; + const saveResult = await this.feedRepository.addActivity(activity); + + if (saveResult.isErr()) { + return err( + new FeedServiceError( + `Failed to save activity: ${saveResult.error.message}`, + ), + ); + } + + return ok(activity); + } catch (error) { + return err( + new FeedServiceError( + `Failed to process activity: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } } diff --git a/src/modules/feeds/domain/value-objects/ActivityType.ts b/src/modules/feeds/domain/value-objects/ActivityType.ts index 95fbac6c..c8266847 100644 --- a/src/modules/feeds/domain/value-objects/ActivityType.ts +++ b/src/modules/feeds/domain/value-objects/ActivityType.ts @@ -3,6 +3,7 @@ import { Result, ok, err } from '../../../../shared/core/Result'; export enum ActivityTypeEnum { CARD_COLLECTED = 'CARD_COLLECTED', + CONNECTION_CREATED = 'CONNECTION_CREATED', } interface ActivityTypeProps { @@ -28,4 +29,8 @@ export class ActivityType extends ValueObject { public static cardCollected(): Result { return this.create(ActivityTypeEnum.CARD_COLLECTED); } + + public static connectionCreated(): Result { + return this.create(ActivityTypeEnum.CONNECTION_CREATED); + } } diff --git a/src/modules/feeds/infrastructure/http/controllers/GetFollowingFeedController.ts b/src/modules/feeds/infrastructure/http/controllers/GetFollowingFeedController.ts index 77d0323e..f51ff1d0 100644 --- a/src/modules/feeds/infrastructure/http/controllers/GetFollowingFeedController.ts +++ b/src/modules/feeds/infrastructure/http/controllers/GetFollowingFeedController.ts @@ -12,6 +12,13 @@ const querySchema = z.object({ beforeActivityId: z.string().optional(), urlType: z.string().optional(), source: z.nativeEnum(ActivitySource).optional(), + activityTypes: z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((val) => { + if (!val) return undefined; + return Array.isArray(val) ? val : [val]; + }), }); export class GetFollowingFeedController extends Controller { @@ -42,6 +49,7 @@ export class GetFollowingFeedController extends Controller { beforeActivityId: params.beforeActivityId, urlType: params.urlType, source: params.source, + activityTypes: params.activityTypes, }); if (result.isErr()) { diff --git a/src/modules/feeds/infrastructure/http/controllers/GetGemActivityFeedController.ts b/src/modules/feeds/infrastructure/http/controllers/GetGemActivityFeedController.ts index 8d5305ec..934c8a7a 100644 --- a/src/modules/feeds/infrastructure/http/controllers/GetGemActivityFeedController.ts +++ b/src/modules/feeds/infrastructure/http/controllers/GetGemActivityFeedController.ts @@ -12,6 +12,13 @@ const querySchema = z.object({ beforeActivityId: z.string().optional(), urlType: z.string().optional(), source: z.nativeEnum(ActivitySource).optional(), + activityTypes: z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((val) => { + if (!val) return undefined; + return Array.isArray(val) ? val : [val]; + }), }); export class GetGemActivityFeedController extends Controller { @@ -37,6 +44,7 @@ export class GetGemActivityFeedController extends Controller { beforeActivityId: params.beforeActivityId, urlType: params.urlType, source: params.source, + activityTypes: params.activityTypes, }); if (result.isErr()) { diff --git a/src/modules/feeds/infrastructure/http/controllers/GetGlobalFeedController.ts b/src/modules/feeds/infrastructure/http/controllers/GetGlobalFeedController.ts index 5922a1c0..4ef00875 100644 --- a/src/modules/feeds/infrastructure/http/controllers/GetGlobalFeedController.ts +++ b/src/modules/feeds/infrastructure/http/controllers/GetGlobalFeedController.ts @@ -12,6 +12,13 @@ const querySchema = z.object({ beforeActivityId: z.string().optional(), urlType: z.string().optional(), source: z.nativeEnum(ActivitySource).optional(), + activityTypes: z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((val) => { + if (!val) return undefined; + return Array.isArray(val) ? val : [val]; + }), }); export class GetGlobalFeedController extends Controller { @@ -37,6 +44,7 @@ export class GetGlobalFeedController extends Controller { beforeActivityId: params.beforeActivityId, urlType: params.urlType, source: params.source, + activityTypes: params.activityTypes, }); if (result.isErr()) { diff --git a/src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts b/src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts index f1736bf3..6cccf8b3 100644 --- a/src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts +++ b/src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts @@ -1,4 +1,4 @@ -import { eq, desc, lt, count, sql, and, gte } from 'drizzle-orm'; +import { eq, desc, lt, count, sql, and, gte, inArray } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { IFeedRepository, @@ -31,6 +31,7 @@ export class DrizzleFeedRepository implements IFeedRepository { id: dto.id, actorId: dto.actorId, cardId: dto.cardId, + connectionId: dto.connectionId, type: dto.type, metadata: dto.metadata, urlType: dto.urlType, @@ -56,6 +57,7 @@ export class DrizzleFeedRepository implements IFeedRepository { id: string; actorId: string; cardId: string | null; + connectionId: string | null; type: string; metadata: any; urlType: string | null; @@ -65,6 +67,17 @@ export class DrizzleFeedRepository implements IFeedRepository { // 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)); } @@ -143,6 +156,7 @@ export class DrizzleFeedRepository implements IFeedRepository { 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, @@ -205,6 +219,7 @@ export class DrizzleFeedRepository implements IFeedRepository { id: string; actorId: string; cardId: string | null; + connectionId: string | null; type: string; metadata: any; urlType: string | null; @@ -214,6 +229,17 @@ export class DrizzleFeedRepository implements IFeedRepository { // 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)); } @@ -290,6 +316,7 @@ export class DrizzleFeedRepository implements IFeedRepository { 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, @@ -464,6 +491,18 @@ export class DrizzleFeedRepository implements IFeedRepository { // 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)); } @@ -505,6 +544,7 @@ export class DrizzleFeedRepository implements IFeedRepository { id: feedActivities.id, actorId: feedActivities.actorId, cardId: feedActivities.cardId, + connectionId: feedActivities.connectionId, type: feedActivities.type, metadata: feedActivities.metadata, urlType: feedActivities.urlType, @@ -543,6 +583,7 @@ export class DrizzleFeedRepository implements IFeedRepository { 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, diff --git a/src/modules/feeds/infrastructure/repositories/mappers/FeedActivityMapper.ts b/src/modules/feeds/infrastructure/repositories/mappers/FeedActivityMapper.ts index f92122d9..7c13b78e 100644 --- a/src/modules/feeds/infrastructure/repositories/mappers/FeedActivityMapper.ts +++ b/src/modules/feeds/infrastructure/repositories/mappers/FeedActivityMapper.ts @@ -3,6 +3,7 @@ import { FeedActivity, ActivityMetadata, CardCollectedMetadata, + ConnectionCreatedMetadata, } from '../../../domain/FeedActivity'; import { ActivityType, @@ -11,6 +12,7 @@ import { import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../../../cards/domain/value-objects/CardId'; import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; +import { ConnectionId } from '../../../../cards/domain/value-objects/ConnectionId'; import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; import { err, ok, Result } from '../../../../../shared/core/Result'; @@ -19,6 +21,7 @@ export interface FeedActivityDTO { id: string; actorId: string; cardId?: string; + connectionId?: string; type: string; metadata: ActivityMetadata; urlType?: string; @@ -77,6 +80,26 @@ export class FeedActivityMapper { return ok(activityResult.value); } + if (dto.type === ActivityTypeEnum.CONNECTION_CREATED) { + const metadata = dto.metadata as ConnectionCreatedMetadata; + const connectionIdResult = ConnectionId.createFromString( + metadata.connectionId, + ); + if (connectionIdResult.isErr()) return err(connectionIdResult.error); + + const activityResult = FeedActivity.createConnectionCreated( + actorIdResult.value, + connectionIdResult.value, + dto.source, + dto.createdAt, + new UniqueEntityID(dto.id), + ); + + if (activityResult.isErr()) return err(activityResult.error); + + return ok(activityResult.value); + } + return err(new Error(`Unsupported activity type: ${dto.type}`)); } catch (error) { return err(error as Error); @@ -85,6 +108,7 @@ export class FeedActivityMapper { public static toPersistence(activity: FeedActivity): FeedActivityDTO { let cardId: string | undefined; + let connectionId: string | undefined; // Extract cardId for CARD_COLLECTED activities if (activity.cardCollected) { @@ -92,10 +116,17 @@ export class FeedActivityMapper { cardId = metadata.cardId; } + // Extract connectionId for CONNECTION_CREATED activities + if (activity.connectionCreated) { + const metadata = activity.metadata as ConnectionCreatedMetadata; + connectionId = metadata.connectionId; + } + return { id: activity.activityId.getStringValue(), actorId: activity.actorId.value, cardId, + connectionId, type: activity.type.value, metadata: activity.metadata, urlType: activity.urlType, diff --git a/src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts b/src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts index ca66536f..d4434e08 100644 --- a/src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts +++ b/src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts @@ -12,8 +12,9 @@ export const feedActivities = pgTable( { id: uuid('id').primaryKey(), actorId: text('actor_id').notNull(), // The DID of the user who performed the activity - cardId: text('card_id'), // Extracted card ID for faster deduplication queries - type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED') + cardId: text('card_id'), // Extracted card ID for faster deduplication queries (CARD_COLLECTED) + connectionId: text('connection_id'), // Extracted connection ID for faster queries (CONNECTION_CREATED) + type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED', 'CONNECTION_CREATED') metadata: jsonb('metadata').notNull(), // Activity-specific metadata urlType: text('url_type'), // Optional URL type from the card source: text('source'), // Optional source - 'margin' for Margin content, null for Cosmik @@ -50,6 +51,10 @@ export const feedActivities = pgTable( ), // Index for card-based queries cardIdIdx: index('feed_activities_card_id_idx').on(table.cardId), + // Index for connection-based queries + connectionIdIdx: index('feed_activities_connection_id_idx').on( + table.connectionId, + ), // Index for filtering by source (Margin content) sourceIdx: index('feed_activities_source_idx').on(table.source), }), diff --git a/src/modules/feeds/tests/infrastructure/DrizzleFeedRepository.integration.test.ts b/src/modules/feeds/tests/infrastructure/DrizzleFeedRepository.integration.test.ts index a558129a..34ea2833 100644 --- a/src/modules/feeds/tests/infrastructure/DrizzleFeedRepository.integration.test.ts +++ b/src/modules/feeds/tests/infrastructure/DrizzleFeedRepository.integration.test.ts @@ -6,11 +6,15 @@ import postgres from 'postgres'; import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { DrizzleFeedRepository } from '../../infrastructure/repositories/DrizzleFeedRepository'; import { FeedActivity } from '../../domain/FeedActivity'; +import { ActivityTypeEnum } from '../../domain/value-objects/ActivityType'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; import { CardId } from '../../../cards/domain/value-objects/CardId'; import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; +import { ConnectionId } from '../../../cards/domain/value-objects/ConnectionId'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; import { feedActivities } from '../../infrastructure/repositories/schema/feedActivity.sql'; import { createTestSchema } from '../../../cards/tests/test-utils/createTestSchema'; +import { CardCollectedMetadata } from '../../domain/FeedActivity'; describe('DrizzleFeedRepository', () => { let container: StartedPostgreSqlContainer; @@ -84,10 +88,9 @@ describe('DrizzleFeedRepository', () => { ); expect(retrievedActivity?.actorId.value).toBe(curatorId.value); expect(retrievedActivity?.cardCollected).toBe(true); - expect(retrievedActivity?.metadata.cardId).toBe(cardId.getStringValue()); - expect(retrievedActivity?.metadata.collectionIds).toEqual([ - collectionId.getStringValue(), - ]); + const metadata = retrievedActivity?.metadata as CardCollectedMetadata; + expect(metadata.cardId).toBe(cardId.getStringValue()); + expect(metadata.collectionIds).toEqual([collectionId.getStringValue()]); }); it('should add a card collected activity without collections', async () => { @@ -105,8 +108,9 @@ describe('DrizzleFeedRepository', () => { const retrievedResult = await feedRepository.findById(activity.activityId); const retrievedActivity = retrievedResult.unwrap(); - expect(retrievedActivity?.metadata.cardId).toBe(cardId.getStringValue()); - expect(retrievedActivity?.metadata.collectionIds).toBeUndefined(); + const metadata = retrievedActivity?.metadata as CardCollectedMetadata; + expect(metadata.cardId).toBe(cardId.getStringValue()); + expect(metadata.collectionIds).toBeUndefined(); }); it('should retrieve global feed with pagination', async () => { @@ -307,13 +311,158 @@ describe('DrizzleFeedRepository', () => { const retrievedResult = await feedRepository.findById(activity.activityId); const retrievedActivity = retrievedResult.unwrap(); - expect(retrievedActivity?.metadata.cardId).toBe(cardId.getStringValue()); - expect(retrievedActivity?.metadata.collectionIds).toEqual([ + const metadata = retrievedActivity?.metadata as CardCollectedMetadata; + expect(metadata.cardId).toBe(cardId.getStringValue()); + expect(metadata.collectionIds).toEqual([ collectionId.getStringValue(), 'collection-456', ]); }); + describe('activity type filtering', () => { + it('should filter by single activity type', async () => { + // Add a CARD_COLLECTED activity + const cardActivity = FeedActivity.createCardCollected(curatorId, cardId, [ + collectionId, + ]).unwrap(); + await feedRepository.addActivity(cardActivity); + + // Add a CONNECTION_CREATED activity + const connectionId = ConnectionId.create(new UniqueEntityID()).unwrap(); + const connectionActivity = FeedActivity.createConnectionCreated( + curatorId, + connectionId, + ).unwrap(); + await feedRepository.addActivity(connectionActivity); + + // Filter to only CARD_COLLECTED + const cardOnlyResult = await feedRepository.getGlobalFeed({ + page: 1, + limit: 10, + activityTypes: [ActivityTypeEnum.CARD_COLLECTED], + }); + + const cardOnlyFeed = cardOnlyResult.unwrap(); + expect(cardOnlyFeed.activities).toHaveLength(1); + expect(cardOnlyFeed.activities[0]!.cardCollected).toBe(true); + expect(cardOnlyFeed.activities[0]!.connectionCreated).toBe(false); + + // Filter to only CONNECTION_CREATED + const connectionOnlyResult = await feedRepository.getGlobalFeed({ + page: 1, + limit: 10, + activityTypes: [ActivityTypeEnum.CONNECTION_CREATED], + }); + + const connectionOnlyFeed = connectionOnlyResult.unwrap(); + expect(connectionOnlyFeed.activities).toHaveLength(1); + expect(connectionOnlyFeed.activities[0]!.cardCollected).toBe(false); + expect(connectionOnlyFeed.activities[0]!.connectionCreated).toBe(true); + }); + + it('should filter by multiple activity types', async () => { + // Add a CARD_COLLECTED activity + const cardActivity = FeedActivity.createCardCollected(curatorId, cardId, [ + collectionId, + ]).unwrap(); + await feedRepository.addActivity(cardActivity); + + // Add a CONNECTION_CREATED activity + const connectionId = ConnectionId.create(new UniqueEntityID()).unwrap(); + const connectionActivity = FeedActivity.createConnectionCreated( + curatorId, + connectionId, + ).unwrap(); + await feedRepository.addActivity(connectionActivity); + + // Filter to both types + const bothTypesResult = await feedRepository.getGlobalFeed({ + page: 1, + limit: 10, + activityTypes: [ + ActivityTypeEnum.CARD_COLLECTED, + ActivityTypeEnum.CONNECTION_CREATED, + ], + }); + + const bothTypesFeed = bothTypesResult.unwrap(); + expect(bothTypesFeed.activities).toHaveLength(2); + }); + + it('should return all types when no filter specified', async () => { + // Add a CARD_COLLECTED activity + const cardActivity = FeedActivity.createCardCollected(curatorId, cardId, [ + collectionId, + ]).unwrap(); + await feedRepository.addActivity(cardActivity); + + // Add a CONNECTION_CREATED activity + const connectionId = ConnectionId.create(new UniqueEntityID()).unwrap(); + const connectionActivity = FeedActivity.createConnectionCreated( + curatorId, + connectionId, + ).unwrap(); + await feedRepository.addActivity(connectionActivity); + + // No filter - should return both + const allTypesResult = await feedRepository.getGlobalFeed({ + page: 1, + limit: 10, + }); + + const allTypesFeed = allTypesResult.unwrap(); + expect(allTypesFeed.activities).toHaveLength(2); + }); + + it('should work correctly with pagination when filtering by type', async () => { + // Add 5 CARD_COLLECTED activities + for (let i = 0; i < 5; i++) { + const activity = FeedActivity.createCardCollected( + curatorId, + CardId.create(new UniqueEntityID()).unwrap(), + [collectionId], + ).unwrap(); + await feedRepository.addActivity(activity); + await new Promise((resolve) => setTimeout(resolve, 10)); // Ensure different timestamps + } + + // Add 3 CONNECTION_CREATED activities + for (let i = 0; i < 3; i++) { + const connectionId = ConnectionId.create(new UniqueEntityID()).unwrap(); + const activity = FeedActivity.createConnectionCreated( + curatorId, + connectionId, + ).unwrap(); + await feedRepository.addActivity(activity); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Get first page of CARD_COLLECTED only (limit 2) + const page1Result = await feedRepository.getGlobalFeed({ + page: 1, + limit: 2, + activityTypes: [ActivityTypeEnum.CARD_COLLECTED], + }); + + const page1Feed = page1Result.unwrap(); + expect(page1Feed.activities).toHaveLength(2); + expect(page1Feed.totalCount).toBe(5); + expect(page1Feed.hasMore).toBe(true); + expect(page1Feed.activities.every((a) => a.cardCollected)).toBe(true); + + // Get second page + const page2Result = await feedRepository.getGlobalFeed({ + page: 2, + limit: 2, + activityTypes: [ActivityTypeEnum.CARD_COLLECTED], + }); + + const page2Feed = page2Result.unwrap(); + expect(page2Feed.activities).toHaveLength(2); + expect(page2Feed.hasMore).toBe(true); + }); + }); + describe('deduplication and merging', () => { it('should find recent card collected activity within time window', async () => { const baseTime = new Date(); @@ -448,7 +597,8 @@ describe('DrizzleFeedRepository', () => { ); const retrievedActivity = retrievedResult.unwrap(); - expect(retrievedActivity?.metadata.collectionIds).toEqual([ + const metadata = retrievedActivity?.metadata as CardCollectedMetadata; + expect(metadata.collectionIds).toEqual([ collectionId.getStringValue(), newCollection.getStringValue(), ]); @@ -476,10 +626,9 @@ describe('DrizzleFeedRepository', () => { ); const retrievedActivity = retrievedResult.unwrap(); - expect(retrievedActivity?.metadata.collectionIds).toEqual([ - collectionId.getStringValue(), - ]); - expect(retrievedActivity?.metadata.collectionIds).toHaveLength(1); + const metadata = retrievedActivity?.metadata as CardCollectedMetadata; + expect(metadata.collectionIds).toEqual([collectionId.getStringValue()]); + expect(metadata.collectionIds).toHaveLength(1); }); it('should find most recent activity when multiple exist', async () => { diff --git a/src/shared/infrastructure/database/migrations/0020_closed_supreme_intelligence.sql b/src/shared/infrastructure/database/migrations/0020_closed_supreme_intelligence.sql new file mode 100644 index 00000000..6d159923 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0020_closed_supreme_intelligence.sql @@ -0,0 +1,2 @@ +ALTER TABLE "feed_activities" ADD COLUMN "connection_id" text;--> statement-breakpoint +CREATE INDEX "feed_activities_connection_id_idx" ON "feed_activities" USING btree ("connection_id"); \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0020_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0020_snapshot.json new file mode 100644 index 00000000..0819593e --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0020_snapshot.json @@ -0,0 +1,1792 @@ +{ + "id": "4a8abde0-ad5a-4280-bf0e-dd8187d1c4b0", + "prevId": "f2ec87f6-d879-420b-b651-c8b389b11efd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cards_author_url_idx": { + "name": "cards_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_author_id_idx": { + "name": "cards_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_type_updated_at": { + "name": "idx_cards_type_updated_at", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type": { + "name": "idx_cards_url_type", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type_filter": { + "name": "idx_cards_url_type_filter", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_parent_type": { + "name": "idx_cards_parent_type", + "columns": [ + { + "expression": "parent_card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "type = 'NOTE'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["parent_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_via_card_id_cards_id_fk": { + "name": "cards_via_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_cards_card_id_idx": { + "name": "collection_cards_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_cards_collection_id_idx": { + "name": "collection_cards_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_collection_added": { + "name": "idx_collection_cards_collection_added", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_card_collection": { + "name": "idx_collection_cards_card_collection", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_added_by_added_at": { + "name": "idx_collection_cards_added_by_added_at", + "columns": [ + { + "expression": "added_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_via_card_id_cards_id_fk": { + "name": "collection_cards_via_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collections_author_id_idx": { + "name": "collections_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_author_updated_at_idx": { + "name": "collections_author_updated_at_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url_metadata": { + "name": "source_url_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url_metadata": { + "name": "target_url_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_curator_id_idx": { + "name": "connections_curator_id_idx", + "columns": [ + { + "expression": "curator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_source_idx": { + "name": "connections_source_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_target_idx": { + "name": "connections_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_created_at_idx": { + "name": "connections_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_curator_created_at_idx": { + "name": "connections_curator_created_at_idx", + "columns": [ + { + "expression": "curator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_published_record_id_published_records_id_fk": { + "name": "connections_published_record_id_published_records_id_fk", + "tableFrom": "connections", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_library_memberships_user_type_covering": { + "name": "idx_library_memberships_user_type_covering", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": ["card_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_activities_type_idx": { + "name": "feed_activities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_idx": { + "name": "feed_activities_url_type_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_created_at_idx": { + "name": "feed_activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_created_at_idx": { + "name": "feed_activities_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_created_at_idx": { + "name": "feed_activities_url_type_created_at_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_url_type_created_at_idx": { + "name": "feed_activities_type_url_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_dedup_idx": { + "name": "feed_activities_dedup_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_card_id_idx": { + "name": "feed_activities_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_connection_id_idx": { + "name": "feed_activities_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_source_idx": { + "name": "feed_activities_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.following_feed_items": { + "name": "following_feed_items", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_following_feed_user_time": { + "name": "idx_following_feed_user_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "following_feed_items_activity_id_feed_activities_id_fk": { + "name": "following_feed_items_activity_id_feed_activities_id_fk", + "tableFrom": "following_feed_items", + "tableTo": "feed_activities", + "columnsFrom": ["activity_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "following_feed_items_user_id_activity_id_pk": { + "name": "following_feed_items_user_id_activity_id_pk", + "columns": ["user_id", "activity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_idx": { + "name": "notifications_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_created_at_idx": { + "name": "notifications_recipient_created_at_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_statuses": { + "name": "sync_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_state": { + "name": "sync_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_attempt_at": { + "name": "last_sync_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error_message": { + "name": "sync_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "records_processed": { + "name": "records_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sync_statuses_curator_id_unique": { + "name": "sync_statuses_curator_id_unique", + "nullsNotDistinct": false, + "columns": ["curator_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_did"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_follows_follower": { + "name": "idx_follows_follower", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_follows_target": { + "name": "idx_follows_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_published_record_id_published_records_id_fk": { + "name": "follows_published_record_id_published_records_id_fk", + "tableFrom": "follows", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_target_id_target_type_pk": { + "name": "follows_follower_id_target_id_target_type_pk", + "columns": ["follower_id", "target_id", "target_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index 08921973..94ee65e2 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1773700838261, "tag": "0019_legal_fallen_one", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1773944859810, + "tag": "0020_closed_supreme_intelligence", + "breakpoints": true } ] } diff --git a/src/shared/infrastructure/events/BullMQEventPublisher.ts b/src/shared/infrastructure/events/BullMQEventPublisher.ts index fed397a3..00dbf894 100644 --- a/src/shared/infrastructure/events/BullMQEventPublisher.ts +++ b/src/shared/infrastructure/events/BullMQEventPublisher.ts @@ -93,6 +93,8 @@ export class BullMQEventPublisher implements IEventPublisher { return [QueueNames.NOTIFICATIONS]; case EventNames.USER_UNFOLLOWED_TARGET: return [QueueNames.NOTIFICATIONS]; + case EventNames.CONNECTION_CREATED: + return [QueueNames.FEEDS]; default: return [QueueNames.FEEDS]; } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 91b47a65..b48961ec 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -444,6 +444,7 @@ export class UseCaseFactory { services.profileService, repositories.cardQueryRepository, repositories.collectionRepository, + repositories.connectionRepository, repositories.followsRepository, ), getGemActivityFeedUseCase: new GetGemActivityFeedUseCase( @@ -458,6 +459,7 @@ export class UseCaseFactory { services.profileService, repositories.cardQueryRepository, repositories.collectionRepository, + repositories.connectionRepository, ), addActivityToFeedUseCase: new AddActivityToFeedUseCase( services.feedService, diff --git a/src/shared/infrastructure/processes/FeedWorkerProcess.ts b/src/shared/infrastructure/processes/FeedWorkerProcess.ts index 5435168d..10697cf5 100644 --- a/src/shared/infrastructure/processes/FeedWorkerProcess.ts +++ b/src/shared/infrastructure/processes/FeedWorkerProcess.ts @@ -6,6 +6,7 @@ import { import { UseCaseFactory } from '../http/factories/UseCaseFactory'; import { CardAddedToLibraryEventHandler } from '../../../modules/feeds/application/eventHandlers/CardAddedToLibraryEventHandler'; import { CardAddedToCollectionEventHandler } from '../../../modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler'; +import { ConnectionCreatedEventHandler } from '../../../modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler'; import { QueueNames } from '../events/QueueConfig'; import { EventNames } from '../events/EventConfig'; import { BaseWorkerProcess } from './BaseWorkerProcess'; @@ -44,6 +45,9 @@ export class FeedWorkerProcess extends BaseWorkerProcess { const cardAddedToCollectionHandler = new CardAddedToCollectionEventHandler( useCases.addActivityToFeedUseCase, ); + const connectionCreatedHandler = new ConnectionCreatedEventHandler( + useCases.addActivityToFeedUseCase, + ); await subscriber.subscribe( EventNames.CARD_ADDED_TO_LIBRARY, @@ -54,5 +58,10 @@ export class FeedWorkerProcess extends BaseWorkerProcess { EventNames.CARD_ADDED_TO_COLLECTION, cardAddedToCollectionHandler, ); + + await subscriber.subscribe( + EventNames.CONNECTION_CREATED, + connectionCreatedHandler, + ); } } diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index 879db176..1fa8cd24 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -128,12 +128,14 @@ export interface GetGlobalFeedParams extends PaginationParams { beforeActivityId?: string; // For cursor-based pagination urlType?: UrlType; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } export interface GetFollowingFeedParams extends PaginationParams { beforeActivityId?: string; // For cursor-based pagination urlType?: UrlType; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } export interface LoginWithAppPasswordRequest { @@ -232,6 +234,7 @@ export interface GetGemActivityFeedParams extends PaginationParams { // Removed beforeActivityId since we're using page-based pagination urlType?: UrlType; // Filter by URL type source?: ActivitySource; // Filter by activity source + activityTypes?: string[]; // Filter by activity types } export interface SearchCollectionsParams extends PaginatedSortedParams { diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 578cd70b..b6c88ec8 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -197,14 +197,25 @@ export interface GenerateExtensionTokensResponse { } // Feed response types -export interface FeedItem { +export interface BaseFeedItem { id: string; user: User; - card: UrlCard; createdAt: Date; +} + +export interface CardCollectedFeedItem extends BaseFeedItem { + activityType: 'CARD_COLLECTED'; + card: UrlCard; collections: Collection[]; } +export interface ConnectionCreatedFeedItem extends BaseFeedItem { + activityType: 'CONNECTION_CREATED'; + connection: ConnectionWithSourceAndTarget; +} + +export type FeedItem = CardCollectedFeedItem | ConnectionCreatedFeedItem; + export interface GetGlobalFeedResponse { activities: FeedItem[]; pagination: FeedPagination;