From a47164816863b0a44f4f2cf751e93e898d1fa429 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 10:52:46 -0700 Subject: [PATCH 01/12] remove unused api methods about forward and backward connections --- src/types/src/api/requests.ts | 12 ------ src/types/src/api/responses.ts | 24 ----------- src/webapp/api-client/ApiClient.ts | 16 ------- src/webapp/api-client/clients/QueryClient.ts | 42 ------------------- .../editConnectionForm/EditConnectionForm.tsx | 4 +- .../EditConnectionModal.tsx | 4 +- src/webapp/features/connections/lib/dal.ts | 18 -------- 7 files changed, 4 insertions(+), 116 deletions(-) diff --git a/src/types/src/api/requests.ts b/src/types/src/api/requests.ts index 4c9be5dd..879db176 100644 --- a/src/types/src/api/requests.ts +++ b/src/types/src/api/requests.ts @@ -335,18 +335,6 @@ export interface DeleteConnectionRequest { connectionId: string; } -export interface GetForwardConnectionsForUrlParams - extends PaginatedSortedParams { - url: string; - connectionTypes?: ConnectionType[]; -} - -export interface GetBackwardConnectionsForUrlParams - extends PaginatedSortedParams { - url: string; - connectionTypes?: ConnectionType[]; -} - export interface GetConnectionsForUrlParams extends PaginatedSortedParams { url: string; direction?: 'forward' | 'backward' | 'both'; diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index 1dd637d1..578cd70b 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -439,35 +439,11 @@ export interface DeleteConnectionResponse { connectionId: string; } -export interface ConnectionForUrl { - connection: { - id: string; - type?: string; - note?: string; - createdAt: string; - updatedAt: string; - curator: User; - }; - url: UrlView; -} - export interface ConnectionSorting { sortBy: string; sortOrder: 'asc' | 'desc'; } -export interface GetForwardConnectionsForUrlResponse { - connections: ConnectionForUrl[]; - pagination: Pagination; - sorting: ConnectionSorting; -} - -export interface GetBackwardConnectionsForUrlResponse { - connections: ConnectionForUrl[]; - pagination: Pagination; - sorting: ConnectionSorting; -} - export interface GetConnectionsForUrlResponse { connections: ConnectionWithSourceAndTarget[]; pagination: Pagination; diff --git a/src/webapp/api-client/ApiClient.ts b/src/webapp/api-client/ApiClient.ts index 8c0406c2..bf03393e 100644 --- a/src/webapp/api-client/ApiClient.ts +++ b/src/webapp/api-client/ApiClient.ts @@ -110,10 +110,6 @@ import type { DeleteConnectionResponse, GetConnectionsParams, GetConnectionsResponse, - GetForwardConnectionsForUrlParams, - GetForwardConnectionsForUrlResponse, - GetBackwardConnectionsForUrlParams, - GetBackwardConnectionsForUrlResponse, GetConnectionsForUrlParams, GetConnectionsForUrlResponse, ConnectionType, @@ -508,18 +504,6 @@ export class ApiClient { return this.connectionClient.deleteConnection(request); } - async getForwardConnectionsForUrl( - params: GetForwardConnectionsForUrlParams, - ): Promise { - return this.queryClient.getForwardConnectionsForUrl(params); - } - - async getBackwardConnectionsForUrl( - params: GetBackwardConnectionsForUrlParams, - ): Promise { - return this.queryClient.getBackwardConnectionsForUrl(params); - } - async getConnectionsForUrl( params: GetConnectionsForUrlParams, ): Promise { diff --git a/src/webapp/api-client/clients/QueryClient.ts b/src/webapp/api-client/clients/QueryClient.ts index 0200285c..ad7d4667 100644 --- a/src/webapp/api-client/clients/QueryClient.ts +++ b/src/webapp/api-client/clients/QueryClient.ts @@ -51,10 +51,6 @@ import { GetCollectionContributorsResponse, GetConnectionsParams, GetConnectionsResponse, - GetForwardConnectionsForUrlParams, - GetForwardConnectionsForUrlResponse, - GetBackwardConnectionsForUrlParams, - GetBackwardConnectionsForUrlResponse, SearchUrlsParams, SearchUrlsResponse, GetGraphDataParams, @@ -502,44 +498,6 @@ export class QueryClient extends BaseClient { return this.request('GET', endpoint); } - async getForwardConnectionsForUrl( - params: GetForwardConnectionsForUrlParams, - ): Promise { - const searchParams = new URLSearchParams(); - searchParams.set('url', params.url); - if (params.page) searchParams.set('page', params.page.toString()); - if (params.limit) searchParams.set('limit', params.limit.toString()); - if (params.sortBy) searchParams.set('sortBy', params.sortBy); - if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); - if (params.connectionTypes) { - searchParams.set('connectionTypes', params.connectionTypes.join(',')); - } - - return this.request( - 'GET', - `/api/connections/forward?${searchParams}`, - ); - } - - async getBackwardConnectionsForUrl( - params: GetBackwardConnectionsForUrlParams, - ): Promise { - const searchParams = new URLSearchParams(); - searchParams.set('url', params.url); - if (params.page) searchParams.set('page', params.page.toString()); - if (params.limit) searchParams.set('limit', params.limit.toString()); - if (params.sortBy) searchParams.set('sortBy', params.sortBy); - if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); - if (params.connectionTypes) { - searchParams.set('connectionTypes', params.connectionTypes.join(',')); - } - - return this.request( - 'GET', - `/api/connections/backward?${searchParams}`, - ); - } - async getConnectionsForUrl( params: GetConnectionsForUrlParams, ): Promise { diff --git a/src/webapp/features/connections/components/editConnectionForm/EditConnectionForm.tsx b/src/webapp/features/connections/components/editConnectionForm/EditConnectionForm.tsx index 53b05445..4bd6b38f 100644 --- a/src/webapp/features/connections/components/editConnectionForm/EditConnectionForm.tsx +++ b/src/webapp/features/connections/components/editConnectionForm/EditConnectionForm.tsx @@ -23,7 +23,6 @@ import { useForm } from '@mantine/form'; import { notifications } from '@mantine/notifications'; import { useQuery } from '@tanstack/react-query'; import useUpdateConnection from '../../lib/mutations/useUpdateConnection'; -import { ConnectionForUrl } from '@semble/types'; import { createSembleClient } from '@/services/client.apiClient'; import { getDomain } from '@/lib/utils/link'; import { IoIosArrowDown } from 'react-icons/io'; @@ -31,12 +30,13 @@ import { LuChevronsUpDown } from 'react-icons/lu'; import { CONNECTION_TYPES } from '../../const/connectionTypes'; import Link from 'next/link'; import { BsCheck, BsExclamation } from 'react-icons/bs'; +import { ConnectionWithSourceAndTarget } from '@semble/types'; interface Props { onClose: () => void; sourceUrl: string; targetUrl: string; - connection: ConnectionForUrl['connection']; + connection: ConnectionWithSourceAndTarget['connection']; } export default function EditConnectionForm(props: Props) { diff --git a/src/webapp/features/connections/components/editConnectionModal/EditConnectionModal.tsx b/src/webapp/features/connections/components/editConnectionModal/EditConnectionModal.tsx index 64b28c43..24c605c0 100644 --- a/src/webapp/features/connections/components/editConnectionModal/EditConnectionModal.tsx +++ b/src/webapp/features/connections/components/editConnectionModal/EditConnectionModal.tsx @@ -3,14 +3,14 @@ import { Modal } from '@mantine/core'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; import EditConnectionForm from '../editConnectionForm/EditConnectionForm'; -import { ConnectionForUrl } from '@semble/types'; +import { ConnectionWithSourceAndTarget } from '@semble/types'; interface Props { isOpen: boolean; onClose: () => void; sourceUrl: string; targetUrl?: string; - connection?: ConnectionForUrl['connection']; + connection?: ConnectionWithSourceAndTarget['connection']; } export default function EditConnectionModal(props: Props) { diff --git a/src/webapp/features/connections/lib/dal.ts b/src/webapp/features/connections/lib/dal.ts index bd6485a5..25265972 100644 --- a/src/webapp/features/connections/lib/dal.ts +++ b/src/webapp/features/connections/lib/dal.ts @@ -1,8 +1,6 @@ import { verifySessionOnClient, logoutUser } from '@/lib/auth/dal'; import { createSembleClient } from '@/services/client.apiClient'; import { - GetForwardConnectionsForUrlParams, - GetBackwardConnectionsForUrlParams, GetConnectionsForUrlParams, GetConnectionsParams, SearchUrlsParams, @@ -30,22 +28,6 @@ export const createConnection = cache( }, ); -export const getForwardConnectionsForUrl = cache( - async (params: GetForwardConnectionsForUrlParams) => { - const client = createSembleClient(); - const response = await client.getForwardConnectionsForUrl(params); - return response; - }, -); - -export const getBackwardConnectionsForUrl = cache( - async (params: GetBackwardConnectionsForUrlParams) => { - const client = createSembleClient(); - const response = await client.getBackwardConnectionsForUrl(params); - return response; - }, -); - export const getConnectionsForUrl = cache( async (params: GetConnectionsForUrlParams) => { const client = createSembleClient(); -- 2.51.2 From 29a9bd9b03a3476f677446c9f5d4c7eddf0e03e2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 12:51:44 -0700 Subject: [PATCH 02/12] include connections in feed response --- .../tests/test-utils/createTestSchema.ts | 4 + .../ConnectionCreatedEventHandler.ts | 30 + .../commands/AddActivityToFeedUseCase.ts | 98 +- .../queries/GetFollowingFeedUseCase.ts | 337 +++- .../queries/GetGemActivityFeedUseCase.ts | 42 +- .../useCases/queries/GetGlobalFeedUseCase.ts | 355 +++- src/modules/feeds/domain/FeedActivity.ts | 45 +- src/modules/feeds/domain/IFeedRepository.ts | 2 + .../feeds/domain/services/FeedService.ts | 41 + .../domain/value-objects/ActivityType.ts | 5 + .../controllers/GetFollowingFeedController.ts | 8 + .../GetGemActivityFeedController.ts | 8 + .../controllers/GetGlobalFeedController.ts | 8 + .../repositories/DrizzleFeedRepository.ts | 43 +- .../mappers/FeedActivityMapper.ts | 31 + .../repositories/schema/feedActivity.sql.ts | 9 +- .../DrizzleFeedRepository.integration.test.ts | 175 +- .../0020_closed_supreme_intelligence.sql | 2 + .../migrations/meta/0020_snapshot.json | 1792 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../events/BullMQEventPublisher.ts | 2 + .../http/factories/UseCaseFactory.ts | 2 + .../processes/FeedWorkerProcess.ts | 9 + src/types/src/api/requests.ts | 3 + src/types/src/api/responses.ts | 15 +- 25 files changed, 2892 insertions(+), 181 deletions(-) create mode 100644 src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler.ts create mode 100644 src/shared/infrastructure/database/migrations/0020_closed_supreme_intelligence.sql create mode 100644 src/shared/infrastructure/database/migrations/meta/0020_snapshot.json 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; -- 2.51.2 From fad7902ead3365d1a9777821599975f711d03b36 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 13:25:54 -0700 Subject: [PATCH 03/12] include connections on feed --- .../ProfileConnectionItem.tsx | 8 ++- .../feedActivityStatus/FeedActivityStatus.tsx | 10 +++- .../feeds/components/feedItem/FeedItem.tsx | 56 +++++++++++++------ 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx b/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx index e1728d72..0975b66a 100644 --- a/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx +++ b/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx @@ -28,6 +28,7 @@ import { CONNECTION_TYPES } from '../../const/connectionTypes'; interface Props { connection: ConnectionWithSourceAndTarget; curator: User; + showActivityStatus?: boolean; onEdit?: () => void; } @@ -63,16 +64,19 @@ export default function ProfileConnectionItem(props: Props) { alt={`${props.curator.name}'s avatar`} size={'sm'} /> - + {props.curator.name} + {props.showActivityStatus && ( + made a connection + )} {' ยท '} diff --git a/src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx b/src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx index 48b8e1b1..361d859e 100644 --- a/src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx +++ b/src/webapp/features/feeds/components/feedActivityStatus/FeedActivityStatus.tsx @@ -12,7 +12,11 @@ import { Stack, Text, } from '@mantine/core'; -import { FeedItem, Collection, CollectionAccessType } from '@/api-client'; +import { + CardCollectedFeedItem, + Collection, + CollectionAccessType, +} from '@/api-client'; import { Fragment } from 'react'; import Link from 'next/link'; import styles from './FeedActivityStatus.module.css'; @@ -21,8 +25,8 @@ import { getRecordKey } from '@/lib/utils/atproto'; import { sanitizeText } from '@/lib/utils/text'; interface Props { - user: FeedItem['user']; - collections?: FeedItem['collections']; + user: CardCollectedFeedItem['user']; + collections?: CardCollectedFeedItem['collections']; createdAt: Date; note?: string; } diff --git a/src/webapp/features/feeds/components/feedItem/FeedItem.tsx b/src/webapp/features/feeds/components/feedItem/FeedItem.tsx index 0c2ab541..6206ac4d 100644 --- a/src/webapp/features/feeds/components/feedItem/FeedItem.tsx +++ b/src/webapp/features/feeds/components/feedItem/FeedItem.tsx @@ -1,7 +1,11 @@ -import type { FeedItem as FeedItemType } from '@/api-client'; +import type { + FeedItem as FeedItemType, + ConnectionCreatedFeedItem, +} from '@/api-client'; import { Stack } from '@mantine/core'; import UrlCard from '@/features/cards/components/urlCard/UrlCard'; import FeedActivityStatus from '../feedActivityStatus/FeedActivityStatus'; +import ProfileConnectionItem from '@/features/connections/components/profileConnectionItem/ProfileConnectionItem'; import { CardSaveAnalyticsContext } from '@/features/analytics/types'; interface Props { @@ -9,27 +13,47 @@ interface Props { analyticsContext?: CardSaveAnalyticsContext; } +function isConnectionCreatedItem( + item: FeedItemType, +): item is ConnectionCreatedFeedItem { + return item.activityType === 'CONNECTION_CREATED'; +} + export default function FeedItem(props: Props) { + const { item } = props; + + if (isConnectionCreatedItem(item)) { + // CONNECTION_CREATED activity type + return ( + + ); + } + + // CARD_COLLECTED activity type return ( -- 2.51.2 From b6a17bc0a30cd8f58f6c69bf7d3239ff48dc090e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 14:11:06 -0700 Subject: [PATCH 04/12] unity activity type --- .../useCases/commands/AddActivityToFeedUseCase.ts | 2 +- .../useCases/queries/GetFollowingFeedUseCase.ts | 2 +- .../useCases/queries/GetGlobalFeedUseCase.ts | 2 +- src/modules/feeds/domain/FeedActivity.ts | 3 ++- .../feeds/domain/value-objects/ActivityType.ts | 6 ++---- src/types/src/api/responses.ts | 9 +++++++-- src/webapp/api-client/clients/FeedClient.ts | 15 +++++++++++++++ 7 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts index b7692d94..6b377249 100644 --- a/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts @@ -6,7 +6,6 @@ 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'; import { SourceTypeEnum } from '../../../domain/value-objects/SourceType'; @@ -17,6 +16,7 @@ import { FollowTargetType, FollowTargetTypeEnum, } from '../../../../user/domain/value-objects/FollowTargetType'; +import { ActivityType as ActivityTypeEnum } from '@semble/types'; export interface AddCardCollectedActivityDTO { type: ActivityTypeEnum.CARD_COLLECTED; diff --git a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts index b642b2d4..992c45d6 100644 --- a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts @@ -4,7 +4,6 @@ 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, @@ -28,6 +27,7 @@ import { CardCollectedMetadata, ConnectionCreatedMetadata, } from '../../../domain/FeedActivity'; +import { ActivityType as ActivityTypeEnum } from '@semble/types'; export interface GetFollowingFeedQuery { callingUserId: string; diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index f4fdb761..19aaca12 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -29,7 +29,7 @@ import { CardCollectedMetadata, ConnectionCreatedMetadata, } from '../../../domain/FeedActivity'; -import { ActivityTypeEnum } from 'src/modules/feeds/domain/value-objects/ActivityType'; +import { ActivityType as ActivityTypeEnum } from '@semble/types'; export interface GetGlobalFeedQuery { callingUserId?: string; diff --git a/src/modules/feeds/domain/FeedActivity.ts b/src/modules/feeds/domain/FeedActivity.ts index a73b0f38..985fa83d 100644 --- a/src/modules/feeds/domain/FeedActivity.ts +++ b/src/modules/feeds/domain/FeedActivity.ts @@ -2,12 +2,13 @@ import { Entity } from '../../../shared/domain/Entity'; import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID'; import { Result, ok, err } from '../../../shared/core/Result'; import { ActivityId } from './value-objects/ActivityId'; -import { ActivityType, ActivityTypeEnum } from './value-objects/ActivityType'; +import { ActivityType } 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'; +import { ActivityType as ActivityTypeEnum } from '@semble/types'; export class ActivityValidationError extends Error { constructor(message: string) { diff --git a/src/modules/feeds/domain/value-objects/ActivityType.ts b/src/modules/feeds/domain/value-objects/ActivityType.ts index c8266847..149640a5 100644 --- a/src/modules/feeds/domain/value-objects/ActivityType.ts +++ b/src/modules/feeds/domain/value-objects/ActivityType.ts @@ -1,10 +1,8 @@ import { ValueObject } from '../../../../shared/domain/ValueObject'; import { Result, ok, err } from '../../../../shared/core/Result'; +import { ActivityType as ActivityTypeEnum } from '@semble/types'; -export enum ActivityTypeEnum { - CARD_COLLECTED = 'CARD_COLLECTED', - CONNECTION_CREATED = 'CONNECTION_CREATED', -} +export { ActivityTypeEnum }; interface ActivityTypeProps { value: ActivityTypeEnum; diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index b6c88ec8..bcf9d25d 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -197,6 +197,11 @@ export interface GenerateExtensionTokensResponse { } // Feed response types +export enum ActivityType { + CARD_COLLECTED = 'CARD_COLLECTED', + CONNECTION_CREATED = 'CONNECTION_CREATED', +} + export interface BaseFeedItem { id: string; user: User; @@ -204,13 +209,13 @@ export interface BaseFeedItem { } export interface CardCollectedFeedItem extends BaseFeedItem { - activityType: 'CARD_COLLECTED'; + activityType: ActivityType.CARD_COLLECTED; card: UrlCard; collections: Collection[]; } export interface ConnectionCreatedFeedItem extends BaseFeedItem { - activityType: 'CONNECTION_CREATED'; + activityType: ActivityType.CONNECTION_CREATED; connection: ConnectionWithSourceAndTarget; } diff --git a/src/webapp/api-client/clients/FeedClient.ts b/src/webapp/api-client/clients/FeedClient.ts index 669793e1..89f6b10d 100644 --- a/src/webapp/api-client/clients/FeedClient.ts +++ b/src/webapp/api-client/clients/FeedClient.ts @@ -17,6 +17,11 @@ export class FeedClient extends BaseClient { searchParams.set('beforeActivityId', params.beforeActivityId); if (params?.urlType) searchParams.set('urlType', params.urlType); if (params?.source) searchParams.set('source', params.source); + if (params?.activityTypes) { + params.activityTypes.forEach((type) => + searchParams.append('activityTypes', type), + ); + } const queryString = searchParams.toString(); const endpoint = queryString @@ -34,6 +39,11 @@ export class FeedClient extends BaseClient { if (params?.limit) searchParams.set('limit', params.limit.toString()); if (params?.urlType) searchParams.set('urlType', params.urlType); if (params?.source) searchParams.set('source', params.source); + if (params?.activityTypes) { + params.activityTypes.forEach((type) => + searchParams.append('activityTypes', type), + ); + } const queryString = searchParams.toString(); const endpoint = queryString @@ -53,6 +63,11 @@ export class FeedClient extends BaseClient { searchParams.set('beforeActivityId', params.beforeActivityId); if (params?.urlType) searchParams.set('urlType', params.urlType); if (params?.source) searchParams.set('source', params.source); + if (params?.activityTypes) { + params.activityTypes.forEach((type) => + searchParams.append('activityTypes', type), + ); + } const queryString = searchParams.toString(); const endpoint = queryString -- 2.51.2 From d17e3bc6a4eedd36583763c58cf0b70720d181b2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 14:11:53 -0700 Subject: [PATCH 05/12] filter feed by activity type (behind feature flag) --- .../components/feedControls/FeedControls.tsx | 52 ++++++++++++++++++- .../myFeedContainer/MyFeedContainer.tsx | 13 ++++- src/webapp/features/feeds/lib/dal.ts | 6 ++- src/webapp/features/feeds/lib/feedKeys.ts | 36 +++++++++---- .../feeds/lib/queries/useFollowingFeed.tsx | 11 +++- .../feeds/lib/queries/useGemsFeed.tsx | 11 +++- .../feeds/lib/queries/useGlobalFeed.tsx | 11 +++- 7 files changed, 121 insertions(+), 19 deletions(-) diff --git a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx index 67f34039..8ce28023 100644 --- a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx +++ b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx @@ -10,7 +10,7 @@ import { } from '@mantine/core'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { ActivitySource, UrlType } from '@semble/types'; +import { ActivitySource, UrlType, ActivityType } from '@semble/types'; import { useOptimistic, useState, useTransition } from 'react'; import { FaSeedling } from 'react-icons/fa6'; import { IoMdCheckmark } from 'react-icons/io'; @@ -19,6 +19,7 @@ import SembleLogo from '@/assets/semble-logo.svg'; import { getUrlTypeIcon } from '@/lib/utils/icon'; import { upperFirst } from '@mantine/hooks'; import { MdFilterList } from 'react-icons/md'; +import { useFeatureFlags } from '@/lib/clientFeatureFlags'; const sourceOptions = [ { value: null, label: 'All', icon: null }, @@ -43,13 +44,22 @@ const feedOptions = [ { value: 'following' as const, label: 'Following' }, ]; +const activityTypeOptions = [ + { value: ActivityType.CARD_COLLECTED, label: 'Card Collected' }, + { value: ActivityType.CONNECTION_CREATED, label: 'Card Connected' }, +]; + export default function FeedControls() { const router = useRouter(); const searchParams = useSearchParams(); + const { data: featureFlags } = useFeatureFlags(); const sourceFromUrl = searchParams.get('source') as ActivitySource | null; const feedFromUrl = (searchParams.get('feed') as 'global' | 'following') || 'global'; const typeFromUrl = searchParams.get('type') as UrlType | null; + const activityTypesFromUrl = searchParams.getAll('activityTypes') as + | ActivityType[] + | []; const [optimisticSource, setOptimisticSource] = useOptimistic(sourceFromUrl); @@ -59,6 +69,8 @@ export default function FeedControls() { const [optimisticType, setOptimisticType] = useOptimistic( typeFromUrl, ); + const [optimisticActivityTypes, setOptimisticActivityTypes] = + useOptimistic(activityTypesFromUrl); const [typePopoverOpened, setTypePopoverOpened] = useState(false); @@ -120,6 +132,24 @@ export default function FeedControls() { setTypePopoverOpened(false); }; + const handleActivityTypeToggle = (activityType: ActivityType) => { + startTransition(() => { + const currentTypes = optimisticActivityTypes; + const isSelected = currentTypes.includes(activityType); + const nextTypes = isSelected + ? currentTypes.filter((t) => t !== activityType) + : [...currentTypes, activityType]; + + setOptimisticActivityTypes(nextTypes); + + const params = new URLSearchParams(searchParams.toString()); + params.delete('activityTypes'); + nextTypes.forEach((type) => params.append('activityTypes', type)); + + router.push(`?${params.toString()}`, { scroll: false }); + }); + }; + const SelectedTypeIcon = optimisticType === null ? MdFilterList : getUrlTypeIcon(optimisticType); @@ -216,6 +246,26 @@ export default function FeedControls() { + + {featureFlags?.connections && ( + <> + Activity Type + {activityTypeOptions.map((option) => ( + handleActivityTypeToggle(option.value)} + rightSection={ + optimisticActivityTypes.includes(option.value) ? ( + + ) : null + } + closeMenuOnClick={false} + > + {option.label} + + ))} + + )} diff --git a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx index 45d596a0..e7b28cf1 100644 --- a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx +++ b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx @@ -16,7 +16,7 @@ import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; import MyFeedContainerError from './Error.MyFeedContainer'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; import RefetchButton from '@/components/navigation/refetchButton/RefetchButton'; -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { CardSaveSource } from '@/features/analytics/types'; import { useState, useEffect } from 'react'; @@ -29,13 +29,24 @@ export default function MyFeedContainer() { const selectedFeed = (searchParams.get('feed') as 'global' | 'following') || 'global'; + // Parse activityTypes from URL params (can be multiple) + const activityTypesParam = searchParams.getAll('activityTypes'); + const selectedActivityTypes = + activityTypesParam.length > 0 + ? (activityTypesParam.filter((type) => + Object.values(ActivityType).includes(type as ActivityType), + ) as ActivityType[]) + : undefined; + const globalFeed = useGlobalFeed({ urlType: selectedUrlType, source: selectedSource, + activityTypes: selectedActivityTypes, }); const followingFeed = useFollowingFeed({ urlType: selectedUrlType, source: selectedSource, + activityTypes: selectedActivityTypes, enabled: selectedFeed === 'following', }); diff --git a/src/webapp/features/feeds/lib/dal.ts b/src/webapp/features/feeds/lib/dal.ts index 5a8c2d14..859b2cb5 100644 --- a/src/webapp/features/feeds/lib/dal.ts +++ b/src/webapp/features/feeds/lib/dal.ts @@ -1,5 +1,5 @@ import { createSembleClient } from '@/services/client.apiClient'; -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; import { cache } from 'react'; import { verifySessionOnClient } from '@/lib/auth/dal'; @@ -8,6 +8,7 @@ interface PageParams { limit?: number; urlType?: UrlType; source?: ActivitySource; + activityTypes?: ActivityType[]; } export const getGlobalFeed = cache(async (params?: PageParams) => { @@ -17,6 +18,7 @@ export const getGlobalFeed = cache(async (params?: PageParams) => { limit: params?.limit, urlType: params?.urlType, source: params?.source, + activityTypes: params?.activityTypes, }); return response; @@ -29,6 +31,7 @@ export const getGemsActivityFeed = cache(async (params?: PageParams) => { limit: params?.limit, urlType: params?.urlType, source: params?.source, + activityTypes: params?.activityTypes, }); return response; @@ -44,6 +47,7 @@ export const getFollowingFeed = cache(async (params?: PageParams) => { limit: params?.limit, urlType: params?.urlType, source: params?.source, + activityTypes: params?.activityTypes, }); return response; diff --git a/src/webapp/features/feeds/lib/feedKeys.ts b/src/webapp/features/feeds/lib/feedKeys.ts index e5f8db08..aa90b84b 100644 --- a/src/webapp/features/feeds/lib/feedKeys.ts +++ b/src/webapp/features/feeds/lib/feedKeys.ts @@ -1,24 +1,40 @@ -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; export const feedKeys = { all: () => ['feeds'] as const, - infinite: (limit?: number, urlType?: UrlType, source?: ActivitySource) => [ - ...feedKeys.all(), - 'infinite', - limit, - urlType, - source, - ], + infinite: ( + limit?: number, + urlType?: UrlType, + source?: ActivitySource, + activityTypes?: ActivityType[], + ) => [...feedKeys.all(), 'infinite', limit, urlType, source, activityTypes], gems: () => [...feedKeys.all(), 'gems'] as const, gemsInfinite: ( limit?: number, urlType?: UrlType, source?: ActivitySource, - ) => [...feedKeys.gems(), [...feedKeys.infinite()], urlType, limit, source], + activityTypes?: ActivityType[], + ) => [ + ...feedKeys.gems(), + [...feedKeys.infinite()], + urlType, + limit, + source, + activityTypes, + ], following: () => [...feedKeys.all(), 'following'] as const, followingInfinite: ( limit?: number, urlType?: UrlType, source?: ActivitySource, - ) => [...feedKeys.following(), 'infinite', limit, urlType, source] as const, + activityTypes?: ActivityType[], + ) => + [ + ...feedKeys.following(), + 'infinite', + limit, + urlType, + source, + activityTypes, + ] as const, }; diff --git a/src/webapp/features/feeds/lib/queries/useFollowingFeed.tsx b/src/webapp/features/feeds/lib/queries/useFollowingFeed.tsx index 4aeff237..fdfd42a7 100644 --- a/src/webapp/features/feeds/lib/queries/useFollowingFeed.tsx +++ b/src/webapp/features/feeds/lib/queries/useFollowingFeed.tsx @@ -1,12 +1,13 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { getFollowingFeed } from '../dal'; import { feedKeys } from '../feedKeys'; -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; interface Props { limit?: number; urlType?: UrlType; source?: ActivitySource; + activityTypes?: ActivityType[]; enabled?: boolean; } @@ -15,7 +16,12 @@ export default function useFollowingFeed(props?: Props) { const enabled = props?.enabled ?? true; const query = useInfiniteQuery({ - queryKey: feedKeys.followingInfinite(limit, props?.urlType, props?.source), + queryKey: feedKeys.followingInfinite( + limit, + props?.urlType, + props?.source, + props?.activityTypes, + ), staleTime: 10000, initialPageParam: 1, refetchOnWindowFocus: false, @@ -26,6 +32,7 @@ export default function useFollowingFeed(props?: Props) { page: pageParam, urlType: props?.urlType, source: props?.source, + activityTypes: props?.activityTypes, }); }, getNextPageParam: (lastPage) => { diff --git a/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx b/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx index 904d141a..9175a93c 100644 --- a/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx +++ b/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx @@ -1,19 +1,25 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { getGemsActivityFeed } from '../dal'; import { feedKeys } from '../feedKeys'; -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; interface Props { limit?: number; urlType?: UrlType; source?: ActivitySource; + activityTypes?: ActivityType[]; } export default function useGemsFeed(props?: Props) { const limit = props?.limit ?? 15; const query = useSuspenseInfiniteQuery({ - queryKey: feedKeys.gemsInfinite(limit, props?.urlType, props?.source), + queryKey: feedKeys.gemsInfinite( + limit, + props?.urlType, + props?.source, + props?.activityTypes, + ), staleTime: 10000, initialPageParam: 1, refetchOnWindowFocus: false, @@ -23,6 +29,7 @@ export default function useGemsFeed(props?: Props) { page: pageParam, urlType: props?.urlType, source: props?.source, + activityTypes: props?.activityTypes, }); }, getNextPageParam: (lastPage) => { diff --git a/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx b/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx index ef17bf95..27f6321e 100644 --- a/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx +++ b/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx @@ -1,19 +1,25 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { getGlobalFeed } from '../dal'; import { feedKeys } from '../feedKeys'; -import { UrlType, ActivitySource } from '@semble/types'; +import { UrlType, ActivitySource, ActivityType } from '@semble/types'; interface Props { limit?: number; urlType?: UrlType; source?: ActivitySource; + activityTypes?: ActivityType[]; } export default function useGlobalFeed(props?: Props) { const limit = props?.limit ?? 15; const query = useSuspenseInfiniteQuery({ - queryKey: feedKeys.infinite(limit, props?.urlType, props?.source), + queryKey: feedKeys.infinite( + limit, + props?.urlType, + props?.source, + props?.activityTypes, + ), staleTime: 10000, initialPageParam: 1, refetchOnWindowFocus: false, @@ -23,6 +29,7 @@ export default function useGlobalFeed(props?: Props) { page: pageParam, urlType: props?.urlType, source: props?.source, + activityTypes: props?.activityTypes, }); }, getNextPageParam: (lastPage) => { -- 2.51.2 From 35efcd62ffcf445cd92c6c66fab2acfbccccb8bf Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 14:39:35 -0700 Subject: [PATCH 06/12] add a reconstitute method to connection domain object to avoid create event --- src/modules/cards/domain/Connection.ts | 18 ++++++++++++++++++ .../repositories/mappers/ConnectionMapper.ts | 4 ++-- .../utils/InMemoryConnectionRepository.ts | 4 +++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/modules/cards/domain/Connection.ts b/src/modules/cards/domain/Connection.ts index 896f5770..671e6289 100644 --- a/src/modules/cards/domain/Connection.ts +++ b/src/modules/cards/domain/Connection.ts @@ -112,6 +112,24 @@ export class Connection extends AggregateRoot { return ok(connection); } + public static reconstitute( + props: ConnectionProps, + id: UniqueEntityID, + ): Result { + // Validate that source and target are not the same + if (props.source.equals(props.target)) { + return err( + new ConnectionValidationError( + 'Connection source and target cannot be the same', + ), + ); + } + + // Note: No event raising for reconstituted connections + const connection = new Connection(props, id); + return ok(connection); + } + public updateNote( note: ConnectionNote, ): Result { diff --git a/src/modules/cards/infrastructure/repositories/mappers/ConnectionMapper.ts b/src/modules/cards/infrastructure/repositories/mappers/ConnectionMapper.ts index b0402b00..1742b143 100644 --- a/src/modules/cards/infrastructure/repositories/mappers/ConnectionMapper.ts +++ b/src/modules/cards/infrastructure/repositories/mappers/ConnectionMapper.ts @@ -129,8 +129,8 @@ export class ConnectionMapper { } } - // Create the connection - const connectionOrError = Connection.create( + // Reconstitute the connection from the database (doesn't raise events) + const connectionOrError = Connection.reconstitute( { source: sourceOrError.value, target: targetOrError.value, diff --git a/src/modules/cards/tests/utils/InMemoryConnectionRepository.ts b/src/modules/cards/tests/utils/InMemoryConnectionRepository.ts index a59269cc..10b0b8b2 100644 --- a/src/modules/cards/tests/utils/InMemoryConnectionRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryConnectionRepository.ts @@ -20,11 +20,13 @@ export class InMemoryConnectionRepository implements IConnectionRepository { } private clone(connection: Connection): Connection { - const connectionResult = Connection.create( + const connectionResult = Connection.reconstitute( { curatorId: connection.curatorId, source: connection.source, target: connection.target, + sourceUrlMetadata: connection.sourceUrlMetadata, + targetUrlMetadata: connection.targetUrlMetadata, type: connection.type, note: connection.note, publishedRecordId: connection.publishedRecordId, -- 2.51.2 From f3b0b05bc4581d7a6347f704efbc41a601230acb Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 15:06:47 -0700 Subject: [PATCH 07/12] in mem events and repo working with connections --- .../infrastructure/InMemoryFeedRepository.ts | 27 ++++++++++++++++--- .../processes/InMemoryEventWorkerProcess.ts | 10 +++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts b/src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts index 2dd3d42b..b0d24b4d 100644 --- a/src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts +++ b/src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts @@ -44,9 +44,16 @@ export class InMemoryFeedRepository implements IFeedRepository { options: FeedQueryOptions, ): Promise> { try { - const { page, limit, beforeActivityId, urlType } = options; + const { page, limit, beforeActivityId, urlType, activityTypes } = options; let filteredActivities = [...this.activities]; + // Filter by activity types if provided + if (activityTypes && activityTypes.length > 0) { + filteredActivities = filteredActivities.filter((activity) => + activityTypes.includes(activity.type.value), + ); + } + // Filter by URL type if provided if (urlType) { filteredActivities = filteredActivities.filter( @@ -96,7 +103,7 @@ export class InMemoryFeedRepository implements IFeedRepository { options: FeedQueryOptions, ): Promise> { try { - const { page, limit, beforeActivityId, urlType } = options; + const { page, limit, beforeActivityId, urlType, activityTypes } = options; const collectionIdStrings = collectionIds.map((id) => id.getStringValue(), ); @@ -114,6 +121,13 @@ export class InMemoryFeedRepository implements IFeedRepository { return false; }); + // Filter by activity types if provided + if (activityTypes && activityTypes.length > 0) { + filteredActivities = filteredActivities.filter((activity) => + activityTypes.includes(activity.type.value), + ); + } + // Filter by URL type if provided if (urlType) { filteredActivities = filteredActivities.filter( @@ -244,7 +258,7 @@ export class InMemoryFeedRepository implements IFeedRepository { options: FeedQueryOptions, ): Promise> { try { - const { page, limit, beforeActivityId, urlType } = options; + const { page, limit, beforeActivityId, urlType, activityTypes } = options; // Get activity IDs for this user's following feed const userActivityIds = this.followingFeedItems.get(userId) || new Set(); @@ -254,6 +268,13 @@ export class InMemoryFeedRepository implements IFeedRepository { userActivityIds.has(activity.activityId.getStringValue()), ); + // Filter by activity types if provided + if (activityTypes && activityTypes.length > 0) { + filteredActivities = filteredActivities.filter((activity) => + activityTypes.includes(activity.type.value), + ); + } + // Filter by URL type if provided if (urlType) { filteredActivities = filteredActivities.filter( diff --git a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts index 9b0d206b..7833cde8 100644 --- a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts +++ b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts @@ -21,6 +21,7 @@ import { RepositoryFactory, Repositories, } from '../http/factories/RepositoryFactory'; +import { ConnectionCreatedEventHandler } from 'src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler'; export class InMemoryEventWorkerProcess implements IProcess { constructor(private configService: EnvironmentConfigService) {} @@ -102,6 +103,10 @@ export class InMemoryEventWorkerProcess implements IProcess { repositories.notificationRepository, ); + const connectionCreatedHandler = new ConnectionCreatedEventHandler( + useCases.addActivityToFeedUseCase, + ); + // Register feed handlers await subscriber.subscribe( EventNames.CARD_ADDED_TO_LIBRARY, @@ -152,5 +157,10 @@ export class InMemoryEventWorkerProcess implements IProcess { EventNames.USER_UNFOLLOWED_TARGET, userUnfollowedTargetHandler, ); + + await subscriber.subscribe( + EventNames.CONNECTION_CREATED, + connectionCreatedHandler, + ); } } -- 2.51.2 From 3984effa624d457e143bf13c916c5ba3da07758e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 16:01:53 -0700 Subject: [PATCH 08/12] include connection created notifications --- .../ConnectionCreatedEventHandler.ts | 125 +++++++++++++++++ .../commands/CreateNotificationUseCase.ts | 83 +++++++++-- .../queries/GetMyNotificationsUseCase.ts | 132 ++++++++++++++++++ .../domain/INotificationRepository.ts | 12 ++ .../notifications/domain/Notification.ts | 27 ++++ .../domain/services/NotificationService.ts | 49 +++++++ .../domain/value-objects/NotificationType.ts | 4 + .../DrizzleNotificationRepository.ts | 64 +++++++++ .../InMemoryNotificationRepository.ts | 44 +++++- .../events/BullMQEventPublisher.ts | 2 +- .../http/factories/UseCaseFactory.ts | 1 + .../processes/InMemoryEventWorkerProcess.ts | 14 ++ .../processes/NotificationWorkerProcess.ts | 12 ++ src/types/src/api/responses.ts | 37 ++++- 14 files changed, 588 insertions(+), 18 deletions(-) create mode 100644 src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts diff --git a/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts new file mode 100644 index 00000000..0d45f31f --- /dev/null +++ b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts @@ -0,0 +1,125 @@ +import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; +import { ConnectionCreatedEvent } from '../../../cards/domain/events/ConnectionCreatedEvent'; +import { Result, ok, err } from '../../../../shared/core/Result'; +import { NotificationService } from '../../domain/services/NotificationService'; +import { IConnectionRepository } from '../../../cards/domain/IConnectionRepository'; +import { ICardQueryRepository } from '../../../cards/domain/ICardQueryRepository'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; + +export class ConnectionCreatedEventHandler + implements IEventHandler +{ + constructor( + private notificationService: NotificationService, + private connectionRepository: IConnectionRepository, + private cardQueryRepository: ICardQueryRepository, + ) {} + + async handle(event: ConnectionCreatedEvent): Promise> { + try { + // Get the connection details + const connectionResult = await this.connectionRepository.findById( + event.connectionId, + ); + if (connectionResult.isErr()) { + console.error('Failed to find connection:', connectionResult.error); + return err(connectionResult.error); + } + + const connection = connectionResult.value; + if (!connection) { + console.warn( + 'Connection not found:', + event.connectionId.getStringValue(), + ); + return ok(undefined); + } + + // Only create notifications if both source and target are URLs + if (!connection.source.url || !connection.target.url) { + // One or both are cards, not URLs - skip notification + return ok(undefined); + } + + const sourceUrl = connection.source.url.value; + const targetUrl = connection.target.url.value; + const curatorId = connection.curatorId; + + // Find all users who have either URL in their library + const recipientUserIds = new Set(); + + // Get users with source URL + const sourceLibrariesResult = + await this.cardQueryRepository.getLibrariesForUrl(sourceUrl, { + page: 1, + limit: 1000, // Get all users, consider pagination for large datasets + sortBy: 'CREATED_AT' as any, + sortOrder: 'DESC' as any, + }); + + if (sourceLibrariesResult.items) { + sourceLibrariesResult.items.forEach((library) => { + // Exclude the curator who created the connection + if (library.userId !== curatorId.value) { + recipientUserIds.add(library.userId); + } + }); + } + + // Get users with target URL + const targetLibrariesResult = + await this.cardQueryRepository.getLibrariesForUrl(targetUrl, { + page: 1, + limit: 1000, // Get all users, consider pagination for large datasets + sortBy: 'CREATED_AT' as any, + sortOrder: 'DESC' as any, + }); + + if (targetLibrariesResult.items) { + targetLibrariesResult.items.forEach((library) => { + // Exclude the curator who created the connection + if (library.userId !== curatorId.value) { + recipientUserIds.add(library.userId); + } + }); + } + + // Create notifications for each unique recipient + const notificationPromises = Array.from(recipientUserIds).map( + async (recipientUserId) => { + const recipientIdResult = CuratorId.create(recipientUserId); + if (recipientIdResult.isErr()) { + console.error('Invalid recipient ID:', recipientIdResult.error); + return err(recipientIdResult.error); + } + const recipientId = recipientIdResult.value; + + const notificationResult = + await this.notificationService.createUserConnectedYourUrlNotification( + recipientId, + curatorId, + event.connectionId, + ); + + if (notificationResult.isErr()) { + console.error( + 'Failed to create connection notification:', + notificationResult.error, + ); + // Continue creating other notifications even if one fails + } + + return notificationResult; + }, + ); + + // Wait for all notifications to be created + await Promise.all(notificationPromises); + + return ok(undefined); + } catch (error) { + console.error('Error handling ConnectionCreatedEvent:', error); + return err(error as Error); + } + } +} diff --git a/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts b/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts index 0fb91a4a..c58cec76 100644 --- a/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts +++ b/src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.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 { NotificationService } from '../../../domain/services/NotificationService'; import { NotificationType } from '@semble/types'; @@ -40,11 +41,19 @@ export interface CreateUserAddedYourCollectionNotificationDTO { collectionIds?: string[]; } +export interface CreateUserConnectedYourUrlNotificationDTO { + type: NotificationType.USER_CONNECTED_YOUR_URL; + recipientUserId: string; + actorUserId: string; + connectionId: string; +} + export type CreateNotificationDTO = | CreateUserAddedYourCardNotificationDTO | CreateUserAddedToYourCollectionNotificationDTO | CreateUserAddedYourBskyPostNotificationDTO - | CreateUserAddedYourCollectionNotificationDTO; + | CreateUserAddedYourCollectionNotificationDTO + | CreateUserConnectedYourUrlNotificationDTO; export interface CreateNotificationResponseDTO { notificationId: string; @@ -99,19 +108,21 @@ export class CreateNotificationUseCase } const actorId = actorIdResult.value; - // Validate and create CardId - const cardIdResult = CardId.createFromString(request.cardId); - if (cardIdResult.isErr()) { - return err( - new ValidationError(`Invalid card ID: ${cardIdResult.error.message}`), - ); - } - const cardId = cardIdResult.value; - // Handle different notification types let notificationResult; if (request.type === NotificationType.USER_ADDED_YOUR_CARD) { + // Validate and create CardId + const cardIdResult = CardId.createFromString(request.cardId); + if (cardIdResult.isErr()) { + return err( + new ValidationError( + `Invalid card ID: ${cardIdResult.error.message}`, + ), + ); + } + const cardId = cardIdResult.value; + // Validate collection IDs if provided let collectionIds: CollectionId[] | undefined; if (request.collectionIds && request.collectionIds.length > 0) { @@ -140,6 +151,17 @@ export class CreateNotificationUseCase } else if ( request.type === NotificationType.USER_ADDED_TO_YOUR_COLLECTION ) { + // Validate and create CardId + const cardIdResult = CardId.createFromString(request.cardId); + if (cardIdResult.isErr()) { + return err( + new ValidationError( + `Invalid card ID: ${cardIdResult.error.message}`, + ), + ); + } + const cardId = cardIdResult.value; + // Validate collection ID const collectionIdResult = CollectionId.createFromString( request.collectionId, @@ -160,6 +182,17 @@ export class CreateNotificationUseCase collectionIdResult.value, ); } else if (request.type === NotificationType.USER_ADDED_YOUR_BSKY_POST) { + // Validate and create CardId + const cardIdResult = CardId.createFromString(request.cardId); + if (cardIdResult.isErr()) { + return err( + new ValidationError( + `Invalid card ID: ${cardIdResult.error.message}`, + ), + ); + } + const cardId = cardIdResult.value; + // Validate collection IDs if provided let collectionIds: CollectionId[] | undefined; if (request.collectionIds && request.collectionIds.length > 0) { @@ -186,6 +219,17 @@ export class CreateNotificationUseCase collectionIds, ); } else if (request.type === NotificationType.USER_ADDED_YOUR_COLLECTION) { + // Validate and create CardId + const cardIdResult = CardId.createFromString(request.cardId); + if (cardIdResult.isErr()) { + return err( + new ValidationError( + `Invalid card ID: ${cardIdResult.error.message}`, + ), + ); + } + const cardId = cardIdResult.value; + // Validate collection IDs if provided let collectionIds: CollectionId[] | undefined; if (request.collectionIds && request.collectionIds.length > 0) { @@ -211,6 +255,25 @@ export class CreateNotificationUseCase cardId, collectionIds, ); + } else if (request.type === NotificationType.USER_CONNECTED_YOUR_URL) { + // Validate connection ID + const connectionIdResult = ConnectionId.createFromString( + request.connectionId, + ); + if (connectionIdResult.isErr()) { + return err( + new ValidationError( + `Invalid connection ID: ${connectionIdResult.error.message}`, + ), + ); + } + + notificationResult = + await this.notificationService.createUserConnectedYourUrlNotification( + recipientId, + actorId, + connectionIdResult.value, + ); } else { // Type exhaustiveness check const _exhaustiveCheck: never = request; diff --git a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts index 269f9afd..e83ee0e8 100644 --- a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts +++ b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts @@ -8,6 +8,8 @@ import { IProfileService } from '../../../../cards/domain/services/IProfileServi import { NotificationItem } from '@semble/types'; import { CollectionAccessType } from '../../../../cards/domain/Collection'; import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; +import { IConnectionRepository } from '../../../../cards/domain/IConnectionRepository'; +import { ConnectionId } from '../../../../cards/domain/value-objects/ConnectionId'; export interface GetMyNotificationsDTO { userId: string; @@ -49,6 +51,7 @@ export class GetMyNotificationsUseCase constructor( private notificationRepository: INotificationRepository, private profileService: IProfileService, + private connectionRepository: IConnectionRepository, ) {} async execute( @@ -130,6 +133,135 @@ export class GetMyNotificationsUseCase continue; } + // Handle connection notifications + const metadata = notification as any; + if ( + metadata.connectionId !== undefined && + notification.type === 'USER_CONNECTED_YOUR_URL' + ) { + // Get the connection data + const connectionIdResult = ConnectionId.createFromString( + metadata.connectionId, + ); + if (connectionIdResult.isErr()) { + continue; // Skip if invalid connection ID + } + + const connectionResult = await this.connectionRepository.findById( + connectionIdResult.value, + ); + if (connectionResult.isErr() || !connectionResult.value) { + continue; // Skip if connection not found + } + + const connection = connectionResult.value; + + // Only handle URL connections + if (!connection.source.url || !connection.target.url) { + continue; // Skip if not both URLs + } + + // Build connection notification item + const connectionNotificationItem: NotificationItem = { + id: notification.id, + user: { + id: actorProfile.id, + name: actorProfile.name, + handle: actorProfile.handle, + avatarUrl: actorProfile.avatarUrl, + bannerUrl: actorProfile.bannerUrl, + description: actorProfile.description, + isFollowing: actorProfile.isFollowing, + }, + createdAt: notification.createdAt.toISOString(), + type: notification.type as any, + read: notification.read, + connection: { + connection: { + id: connection.connectionId.getStringValue(), + type: connection.type?.value, + note: connection.note?.value, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + curator: actorProfile, + }, + source: { + url: connection.source.url.value, + metadata: (() => { + const meta = + connection.sourceUrlMetadata?.props || + connection.sourceUrlMetadata; + if (!meta) { + return { url: connection.source.url.value }; + } + // Convert all dates to strings + return { + url: meta.url || connection.source.url.value, + title: meta.title, + description: meta.description, + author: meta.author, + publishedDate: + meta.publishedDate instanceof Date + ? meta.publishedDate.toISOString() + : meta.publishedDate, + siteName: meta.siteName, + imageUrl: meta.imageUrl, + type: meta.type, + retrievedAt: + meta.retrievedAt instanceof Date + ? meta.retrievedAt.toISOString() + : meta.retrievedAt, + doi: meta.doi, + isbn: meta.isbn, + }; + })(), + urlLibraryCount: 0, // TODO: Could fetch if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }, + target: { + url: connection.target.url.value, + metadata: (() => { + const meta = + connection.targetUrlMetadata?.props || + connection.targetUrlMetadata; + if (!meta) { + return { url: connection.target.url.value }; + } + // Convert all dates to strings + return { + url: meta.url || connection.target.url.value, + title: meta.title, + description: meta.description, + author: meta.author, + publishedDate: + meta.publishedDate instanceof Date + ? meta.publishedDate.toISOString() + : meta.publishedDate, + siteName: meta.siteName, + imageUrl: meta.imageUrl, + type: meta.type, + retrievedAt: + meta.retrievedAt instanceof Date + ? meta.retrievedAt.toISOString() + : meta.retrievedAt, + doi: meta.doi, + isbn: meta.isbn, + }; + })(), + urlLibraryCount: 0, // TODO: Could fetch if needed + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }, + }, + }; + + notificationItems.push(connectionNotificationItem); + continue; + } + // Handle follow notifications (no card data) if (notification.followTargetType) { // Transform follow collections with author profiles diff --git a/src/modules/notifications/domain/INotificationRepository.ts b/src/modules/notifications/domain/INotificationRepository.ts index 25e5173f..a768e5d5 100644 --- a/src/modules/notifications/domain/INotificationRepository.ts +++ b/src/modules/notifications/domain/INotificationRepository.ts @@ -80,6 +80,18 @@ export interface EnrichedNotificationResult { createdAt: Date; updatedAt: Date; }>; + + // Connection notification specific data (optional, only for connection notifications) + connectionId?: string; + connectionType?: string; + connectionNote?: string; + connectionCreatedAt?: Date; + connectionUpdatedAt?: Date; + connectionCuratorId?: string; + sourceUrl?: string; + sourceUrlMetadata?: any; + targetUrl?: string; + targetUrlMetadata?: any; } export interface PaginatedEnrichedNotificationResult { diff --git a/src/modules/notifications/domain/Notification.ts b/src/modules/notifications/domain/Notification.ts index 438dfab8..c99942b4 100644 --- a/src/modules/notifications/domain/Notification.ts +++ b/src/modules/notifications/domain/Notification.ts @@ -6,6 +6,7 @@ import { NotificationType } from './value-objects/NotificationType'; 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'; export interface NotificationMetadata { cardId: string; @@ -17,6 +18,10 @@ export interface FollowNotificationMetadata { targetId?: string; // Collection ID if applicable } +export interface ConnectionNotificationMetadata { + connectionId: string; +} + interface NotificationProps { recipientUserId: CuratorId; actorUserId: CuratorId; @@ -224,6 +229,28 @@ export class Notification extends AggregateRoot { }); } + public static createUserConnectedYourUrl( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Result { + const typeResult = NotificationType.userConnectedYourUrl(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: ConnectionNotificationMetadata = { + connectionId: connectionId.getStringValue(), + }; + + return this.create({ + recipientUserId, + actorUserId, + type: typeResult.value, + metadata: metadata as any, + }); + } + public markAsRead(): void { this.props.read = true; this.props.updatedAt = new Date(); diff --git a/src/modules/notifications/domain/services/NotificationService.ts b/src/modules/notifications/domain/services/NotificationService.ts index 33f90529..8143e4e0 100644 --- a/src/modules/notifications/domain/services/NotificationService.ts +++ b/src/modules/notifications/domain/services/NotificationService.ts @@ -5,6 +5,7 @@ import { INotificationRepository } from '../INotificationRepository'; 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'; export class NotificationServiceError extends Error { constructor(message: string) { @@ -309,4 +310,52 @@ export class NotificationService implements DomainService { ); } } + + async createUserConnectedYourUrlNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + connectionId: ConnectionId, + ): Promise> { + try { + // Don't create notification if user is connecting their own URLs + if (recipientUserId.equals(actorUserId)) { + return err( + new NotificationServiceError( + 'Cannot notify user about their own action', + ), + ); + } + + const notificationResult = Notification.createUserConnectedYourUrl( + recipientUserId, + actorUserId, + connectionId, + ); + + if (notificationResult.isErr()) { + return err( + new NotificationServiceError(notificationResult.error.message), + ); + } + + const notification = notificationResult.value; + const saveResult = await this.notificationRepository.save(notification); + + if (saveResult.isErr()) { + return err( + new NotificationServiceError( + `Failed to save notification: ${saveResult.error.message}`, + ), + ); + } + + return ok(notification); + } catch (error) { + return err( + new NotificationServiceError( + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } } diff --git a/src/modules/notifications/domain/value-objects/NotificationType.ts b/src/modules/notifications/domain/value-objects/NotificationType.ts index 29ca52b8..0af34f42 100644 --- a/src/modules/notifications/domain/value-objects/NotificationType.ts +++ b/src/modules/notifications/domain/value-objects/NotificationType.ts @@ -45,4 +45,8 @@ export class NotificationType extends ValueObject { public static userFollowedYourCollection(): Result { return this.create(NotificationTypeEnum.USER_FOLLOWED_YOUR_COLLECTION); } + + public static userConnectedYourUrl(): Result { + return this.create(NotificationTypeEnum.USER_CONNECTED_YOUR_URL); + } } diff --git a/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts index d90515ef..a7534bf8 100644 --- a/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts +++ b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts @@ -711,6 +711,41 @@ export class DrizzleNotificationRepository implements INotificationRepository { followCollectionsResult.map((c) => [c.collectionId, c]), ); + // Fetch connection data for CONNECTION notifications + const connectionIds = notificationsResult + .filter((n) => { + const metadata = n.metadata as any; + return metadata?.connectionId !== undefined; + }) + .map((n) => (n.metadata as any).connectionId) + .filter(Boolean); + + let connectionDataMap = new Map(); + if (connectionIds.length > 0) { + const connectionQuery = this.db + .select({ + id: connections.id, + curatorId: connections.curatorId, + sourceType: connections.sourceType, + sourceValue: connections.sourceValue, + sourceUrlMetadata: connections.sourceUrlMetadata, + targetType: connections.targetType, + targetValue: connections.targetValue, + targetUrlMetadata: connections.targetUrlMetadata, + connectionType: connections.connectionType, + note: connections.note, + createdAt: connections.createdAt, + updatedAt: connections.updatedAt, + }) + .from(connections) + .where(inArray(connections.id, connectionIds)); + + const connectionsResult = await connectionQuery; + connectionsResult.forEach((connection) => { + connectionDataMap.set(connection.id, connection); + }); + } + // Build enriched notifications - process in original chronological order const enrichedNotifications: EnrichedNotificationResult[] = []; @@ -718,6 +753,35 @@ export class DrizzleNotificationRepository implements INotificationRepository { for (const notification of notificationsResult) { const metadata = notification.metadata as any; + // Check if this is a connection notification + if (metadata?.connectionId !== undefined) { + const connectionData = connectionDataMap.get(metadata.connectionId); + if ( + connectionData && + connectionData.sourceType === 'URL' && + connectionData.targetType === 'URL' + ) { + enrichedNotifications.push({ + id: notification.id, + type: notification.type, + read: notification.read, + createdAt: notification.createdAt, + actorUserId: notification.actorUserId, + connectionId: metadata.connectionId, + connectionType: connectionData.connectionType, + connectionNote: connectionData.note, + connectionCreatedAt: connectionData.createdAt, + connectionUpdatedAt: connectionData.updatedAt, + connectionCuratorId: connectionData.curatorId, + sourceUrl: connectionData.sourceValue, + sourceUrlMetadata: connectionData.sourceUrlMetadata, + targetUrl: connectionData.targetValue, + targetUrlMetadata: connectionData.targetUrlMetadata, + }); + } + continue; + } + // Check if this is a follow notification if (metadata?.targetType !== undefined) { // Handle follow notifications diff --git a/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts b/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts index fa69c763..ffa85c5c 100644 --- a/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts +++ b/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts @@ -10,17 +10,24 @@ import { NotificationId } from '../../domain/value-objects/NotificationId'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; import { Result, ok, err } from '../../../../shared/core/Result'; import { ICardQueryRepository } from '../../../cards/domain/ICardQueryRepository'; +import { IConnectionRepository } from '../../../cards/domain/IConnectionRepository'; +import { ConnectionId } from '../../../cards/domain/value-objects/ConnectionId'; export class InMemoryNotificationRepository implements INotificationRepository { private static instance: InMemoryNotificationRepository; private notifications: Map = new Map(); private cardQueryRepository?: ICardQueryRepository; + private connectionRepository?: IConnectionRepository; private constructor() {} // Method to inject dependencies after singleton creation - public setDependencies(cardQueryRepository: ICardQueryRepository): void { + public setDependencies( + cardQueryRepository: ICardQueryRepository, + connectionRepository?: IConnectionRepository, + ): void { this.cardQueryRepository = cardQueryRepository; + this.connectionRepository = connectionRepository; } public static getInstance(): InMemoryNotificationRepository { @@ -240,6 +247,41 @@ export class InMemoryNotificationRepository implements INotificationRepository { for (const notification of paginatedNotifications) { const metadata = notification.metadata as any; + // Check if this is a connection notification + if (metadata?.connectionId !== undefined && this.connectionRepository) { + const connectionIdResult = ConnectionId.createFromString( + metadata.connectionId, + ); + if (connectionIdResult.isOk()) { + const connectionResult = await this.connectionRepository.findById( + connectionIdResult.value, + ); + if (connectionResult.isOk() && connectionResult.value) { + const connection = connectionResult.value; + if (connection.source.url && connection.target.url) { + enrichedNotifications.push({ + id: notification.notificationId.getStringValue(), + type: notification.type.value, + read: notification.read, + createdAt: notification.createdAt, + actorUserId: notification.actorUserId.value, + connectionId: metadata.connectionId, + connectionType: connection.type?.value, + connectionNote: connection.note?.value, + connectionCreatedAt: connection.createdAt, + connectionUpdatedAt: connection.updatedAt, + connectionCuratorId: connection.curatorId.value, + sourceUrl: connection.source.url.value, + sourceUrlMetadata: connection.sourceUrlMetadata, + targetUrl: connection.target.url.value, + targetUrlMetadata: connection.targetUrlMetadata, + }); + continue; + } + } + } + } + // Check if this is a follow notification (has targetType) if (metadata?.targetType !== undefined) { // Handle follow notifications diff --git a/src/shared/infrastructure/events/BullMQEventPublisher.ts b/src/shared/infrastructure/events/BullMQEventPublisher.ts index 00dbf894..48767954 100644 --- a/src/shared/infrastructure/events/BullMQEventPublisher.ts +++ b/src/shared/infrastructure/events/BullMQEventPublisher.ts @@ -94,7 +94,7 @@ export class BullMQEventPublisher implements IEventPublisher { case EventNames.USER_UNFOLLOWED_TARGET: return [QueueNames.NOTIFICATIONS]; case EventNames.CONNECTION_CREATED: - return [QueueNames.FEEDS]; + return [QueueNames.FEEDS, QueueNames.NOTIFICATIONS]; default: return [QueueNames.FEEDS]; } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index b48961ec..0f2b76d5 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -489,6 +489,7 @@ export class UseCaseFactory { getMyNotificationsUseCase: new GetMyNotificationsUseCase( repositories.notificationRepository, services.profileService, + repositories.connectionRepository, ), getUnreadNotificationCountUseCase: new GetUnreadNotificationCountUseCase( repositories.notificationRepository, diff --git a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts index 7833cde8..3ff0ced9 100644 --- a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts +++ b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts @@ -22,6 +22,7 @@ import { Repositories, } from '../http/factories/RepositoryFactory'; import { ConnectionCreatedEventHandler } from 'src/modules/feeds/application/eventHandlers/ConnectionCreatedEventHandler'; +import { ConnectionCreatedEventHandler as NotificationConnectionCreatedEventHandler } from 'src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler'; export class InMemoryEventWorkerProcess implements IProcess { constructor(private configService: EnvironmentConfigService) {} @@ -107,6 +108,13 @@ export class InMemoryEventWorkerProcess implements IProcess { useCases.addActivityToFeedUseCase, ); + const notificationConnectionCreatedHandler = + new NotificationConnectionCreatedEventHandler( + services.notificationService, + repositories.connectionRepository, + repositories.cardQueryRepository, + ); + // Register feed handlers await subscriber.subscribe( EventNames.CARD_ADDED_TO_LIBRARY, @@ -162,5 +170,11 @@ export class InMemoryEventWorkerProcess implements IProcess { EventNames.CONNECTION_CREATED, connectionCreatedHandler, ); + + // Register notification handler for connections + await subscriber.subscribe( + EventNames.CONNECTION_CREATED, + notificationConnectionCreatedHandler, + ); } } diff --git a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts index 447ffea3..8bcc5c3b 100644 --- a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts +++ b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts @@ -17,6 +17,7 @@ import { EventNames } from '../events/EventConfig'; import { BaseWorkerProcess } from './BaseWorkerProcess'; import { IEventSubscriber } from '../../application/events/IEventSubscriber'; import { Repositories } from '../http/factories/RepositoryFactory'; +import { ConnectionCreatedEventHandler } from 'src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler'; export class NotificationWorkerProcess extends BaseWorkerProcess { constructor(configService: EnvironmentConfigService) { @@ -87,6 +88,12 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { repositories.notificationRepository, ); + const connectionCreatedHandler = new ConnectionCreatedEventHandler( + services.notificationService, + repositories.connectionRepository, + repositories.cardQueryRepository, + ); + await subscriber.subscribe( EventNames.CARD_ADDED_TO_LIBRARY, cardAddedToLibraryHandler, @@ -123,5 +130,10 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { EventNames.USER_UNFOLLOWED_TARGET, userUnfollowedTargetHandler, ); + + await subscriber.subscribe( + EventNames.CONNECTION_CREATED, + connectionCreatedHandler, + ); } } diff --git a/src/types/src/api/responses.ts b/src/types/src/api/responses.ts index bcf9d25d..b7ad6856 100644 --- a/src/types/src/api/responses.ts +++ b/src/types/src/api/responses.ts @@ -372,21 +372,46 @@ export enum NotificationType { USER_ADDED_TO_YOUR_COLLECTION = 'USER_ADDED_TO_YOUR_COLLECTION', USER_FOLLOWED_YOU = 'USER_FOLLOWED_YOU', USER_FOLLOWED_YOUR_COLLECTION = 'USER_FOLLOWED_YOUR_COLLECTION', + USER_CONNECTED_YOUR_URL = 'USER_CONNECTED_YOUR_URL', } -export interface NotificationItem { +export interface BaseNotificationItem { id: string; user: User; - card?: UrlCard; // Optional for follow notifications createdAt: string; - collections?: Collection[]; // Optional for follow notifications type: NotificationType; read: boolean; - // Follow notification specific fields - followTargetType?: 'USER' | 'COLLECTION'; - followTargetId?: string; // Collection ID if following a collection } +export interface CardCollectionNotificationItem extends BaseNotificationItem { + type: + | NotificationType.USER_ADDED_YOUR_CARD + | NotificationType.USER_ADDED_YOUR_BSKY_POST + | NotificationType.USER_ADDED_YOUR_COLLECTION + | NotificationType.USER_ADDED_TO_YOUR_COLLECTION; + card: UrlCard; + collections?: Collection[]; +} + +export interface FollowNotificationItem extends BaseNotificationItem { + type: + | NotificationType.USER_FOLLOWED_YOU + | NotificationType.USER_FOLLOWED_YOUR_COLLECTION; + followTargetType: 'USER' | 'COLLECTION'; + followTargetId?: string; // Only present for COLLECTION follows +} + +export interface ConnectionCreatedNotificationItem + extends BaseNotificationItem { + type: NotificationType.USER_CONNECTED_YOUR_URL; + connection: ConnectionWithSourceAndTarget; +} + +export type NotificationItem = + | CardCollectionNotificationItem + | FollowNotificationItem + | ConnectionCreatedNotificationItem; + export interface GetMyNotificationsResponse { notifications: NotificationItem[]; pagination: Pagination; -- 2.51.2 From ce82e8cbeb5f18c45dfc2f5e14e0dcd2369be6e2 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 16:10:27 -0700 Subject: [PATCH 09/12] include connection notification in notification page --- .../ProfileConnectionItem.tsx | 6 +- .../feeds/components/feedItem/FeedItem.tsx | 2 +- .../NotificationActivityStatus.tsx | 2 +- .../notificationItem/NotificationItem.tsx | 177 +++++++++++++----- 4 files changed, 130 insertions(+), 57 deletions(-) diff --git a/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx b/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx index 0975b66a..aea49dc4 100644 --- a/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx +++ b/src/webapp/features/connections/components/profileConnectionItem/ProfileConnectionItem.tsx @@ -28,7 +28,7 @@ import { CONNECTION_TYPES } from '../../const/connectionTypes'; interface Props { connection: ConnectionWithSourceAndTarget; curator: User; - showActivityStatus?: boolean; + activityStatusText?: string; onEdit?: () => void; } @@ -74,8 +74,8 @@ export default function ProfileConnectionItem(props: Props) { > {props.curator.name} - {props.showActivityStatus && ( - made a connection + {props.activityStatusText && ( + {props.activityStatusText} )} {' ยท '} diff --git a/src/webapp/features/feeds/components/feedItem/FeedItem.tsx b/src/webapp/features/feeds/components/feedItem/FeedItem.tsx index 6206ac4d..5249ad58 100644 --- a/src/webapp/features/feeds/components/feedItem/FeedItem.tsx +++ b/src/webapp/features/feeds/components/feedItem/FeedItem.tsx @@ -28,7 +28,7 @@ export default function FeedItem(props: Props) { ); } diff --git a/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx b/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx index 226be7ae..8e4c78e8 100644 --- a/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx +++ b/src/webapp/features/notifications/components/notificationActivityStatus/NotificationActivityStatus.tsx @@ -28,7 +28,7 @@ import { getNotificationTypeIcon } from '../../lib/utils/icon'; interface Props { user: NotificationItem['user']; - collections?: NotificationItem['collections']; + collections?: Collection[]; createdAt: string; type: NotificationType; followButton?: React.ReactNode; diff --git a/src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx b/src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx index bb0acc83..abc94ce0 100644 --- a/src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx +++ b/src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx @@ -1,4 +1,9 @@ -import type { NotificationItem as NotificationItemType } from '@/api-client'; +import type { + NotificationItem as NotificationItemType, + ConnectionCreatedNotificationItem, + CardCollectionNotificationItem, + FollowNotificationItem, +} from '@/api-client'; import { NotificationType } from '@/api-client'; import { Stack, Indicator, Box } from '@mantine/core'; import UrlCard from '@/features/cards/components/urlCard/UrlCard'; @@ -6,36 +11,116 @@ import NotificationActivityStatus from '../notificationActivityStatus/Notificati import FollowButton from '@/features/follows/components/followButton/FollowButton'; import { useRouter } from 'next/navigation'; import { CardSaveAnalyticsContext } from '@/features/analytics/types'; +import ProfileConnectionItem from '@/features/connections/components/profileConnectionItem/ProfileConnectionItem'; interface Props { item: NotificationItemType; analyticsContext?: CardSaveAnalyticsContext; } +function isConnectionCreatedNotification( + item: NotificationItemType, +): item is ConnectionCreatedNotificationItem { + return item.type === NotificationType.USER_CONNECTED_YOUR_URL; +} + +function isCardCollectionNotification( + item: NotificationItemType, +): item is CardCollectionNotificationItem { + return ( + item.type === NotificationType.USER_ADDED_YOUR_CARD || + item.type === NotificationType.USER_ADDED_YOUR_BSKY_POST || + item.type === NotificationType.USER_ADDED_YOUR_COLLECTION || + item.type === NotificationType.USER_ADDED_TO_YOUR_COLLECTION + ); +} + +function isFollowNotificationItem( + item: NotificationItemType, +): item is FollowNotificationItem { + return ( + item.type === NotificationType.USER_FOLLOWED_YOU || + item.type === NotificationType.USER_FOLLOWED_YOUR_COLLECTION + ); +} + export default function NotificationItem(props: Props) { const router = useRouter(); - const isFollowNotification = - props.item.type === NotificationType.USER_FOLLOWED_YOU; - const handleClick = () => { - if (isFollowNotification) { + // Connection notification - render similar to feed item + if (isConnectionCreatedNotification(props.item)) { + return ( + + + + ); + } + + // Follow notification + if (isFollowNotificationItem(props.item)) { + const handleClick = () => { router.push(`/profile/${props.item.user.handle}`); - } - }; + }; - return ( - - + + + e.stopPropagation()}> + + + ) : undefined + } + /> + + + + ); + } + + // Card/collection notification + if (isCardCollectionNotification(props.item)) { + return ( + e.stopPropagation()}> - - - ) : undefined - } /> - {props.item.card && ( - - )} + - - - ); + + ); + } + + // Fallback (should never reach here if all notification types are handled) + return null; } -- 2.51.2 From 8b0cb7e447478fd5c88e293fb2154e5702383db7 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 16:19:21 -0700 Subject: [PATCH 10/12] only notify early testers about connections --- .../ConnectionCreatedEventHandler.ts | 51 ++++++++++++++++++- src/shared/constants/featureFlags.ts | 21 ++++++++ .../processes/InMemoryEventWorkerProcess.ts | 2 + .../processes/NotificationWorkerProcess.ts | 2 + src/webapp/lib/serverFeatureFlags.ts | 22 ++------ 5 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 src/shared/constants/featureFlags.ts diff --git a/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts index 0d45f31f..861ad91c 100644 --- a/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts +++ b/src/modules/notifications/application/eventHandlers/ConnectionCreatedEventHandler.ts @@ -5,6 +5,13 @@ import { NotificationService } from '../../domain/services/NotificationService'; import { IConnectionRepository } from '../../../cards/domain/IConnectionRepository'; import { ICardQueryRepository } from '../../../cards/domain/ICardQueryRepository'; import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { EnvironmentConfigService } from '../../../../shared/infrastructure/config/EnvironmentConfigService'; +import { IIdentityResolutionService } from '../../../atproto/domain/services/IIdentityResolutionService'; +import { DIDOrHandle } from '../../../atproto/domain/DIDOrHandle'; +import { + TEAM_HANDLES, + EARLY_TESTERS_HANDLES, +} from '../../../../shared/constants/featureFlags'; export class ConnectionCreatedEventHandler implements IEventHandler @@ -13,6 +20,8 @@ export class ConnectionCreatedEventHandler private notificationService: NotificationService, private connectionRepository: IConnectionRepository, private cardQueryRepository: ICardQueryRepository, + private environmentConfigService: EnvironmentConfigService, + private identityResolutionService: IIdentityResolutionService, ) {} async handle(event: ConnectionCreatedEvent): Promise> { @@ -84,8 +93,46 @@ export class ConnectionCreatedEventHandler }); } - // Create notifications for each unique recipient - const notificationPromises = Array.from(recipientUserIds).map( + // Filter recipients based on environment + let filteredRecipientUserIds = Array.from(recipientUserIds); + + // In production, only notify team and early testers + const isProduction = + this.environmentConfigService.get().environment === 'prod'; + if (isProduction) { + const allowedHandles = new Set([ + ...TEAM_HANDLES, + ...EARLY_TESTERS_HANDLES, + ]); + + // Filter to only allowed recipients + const filteredRecipients = await Promise.all( + filteredRecipientUserIds.map(async (recipientUserId) => { + const didOrHandleResult = DIDOrHandle.create(recipientUserId); + if (didOrHandleResult.isErr()) { + return null; + } + + const handleResult = + await this.identityResolutionService.resolveToHandle( + didOrHandleResult.value, + ); + if (handleResult.isErr()) { + return null; + } + + const handle = handleResult.value.value; + return allowedHandles.has(handle) ? recipientUserId : null; + }), + ); + + filteredRecipientUserIds = filteredRecipients.filter( + (id): id is string => id !== null, + ); + } + + // Create notifications for each filtered recipient + const notificationPromises = filteredRecipientUserIds.map( async (recipientUserId) => { const recipientIdResult = CuratorId.create(recipientUserId); if (recipientIdResult.isErr()) { diff --git a/src/shared/constants/featureFlags.ts b/src/shared/constants/featureFlags.ts new file mode 100644 index 00000000..cdd3c63d --- /dev/null +++ b/src/shared/constants/featureFlags.ts @@ -0,0 +1,21 @@ +export const TEAM_HANDLES = new Set([ + 'wesleyfinck.org', + 'ronentk.me', + 'pouriade.com', +]); + +export const EARLY_TESTERS_HANDLES = new Set([ + 'bmann.ca', + 'tynanpurdy.com', + 'erlend.sh', + 'tgoerke.bsky.social', + 'psingletary.com', + 'hilarybaumann.com', + 'cosmik.network', + 'semble.so', + 'atproto.science', + 'chrisshank.com', + 'jasmine-pyz.bsky.social', + 'uppy-hacker.bsky.social', + 'joelchan86.bsky.social', +]); diff --git a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts index 3ff0ced9..765631ac 100644 --- a/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts +++ b/src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts @@ -113,6 +113,8 @@ export class InMemoryEventWorkerProcess implements IProcess { services.notificationService, repositories.connectionRepository, repositories.cardQueryRepository, + this.configService, + services.identityResolutionService, ); // Register feed handlers diff --git a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts index 8bcc5c3b..8296a545 100644 --- a/src/shared/infrastructure/processes/NotificationWorkerProcess.ts +++ b/src/shared/infrastructure/processes/NotificationWorkerProcess.ts @@ -92,6 +92,8 @@ export class NotificationWorkerProcess extends BaseWorkerProcess { services.notificationService, repositories.connectionRepository, repositories.cardQueryRepository, + this.configService, + services.identityResolutionService, ); await subscriber.subscribe( diff --git a/src/webapp/lib/serverFeatureFlags.ts b/src/webapp/lib/serverFeatureFlags.ts index b91594e2..d22822de 100644 --- a/src/webapp/lib/serverFeatureFlags.ts +++ b/src/webapp/lib/serverFeatureFlags.ts @@ -1,22 +1,8 @@ import { getServerAuthStatus } from './serverAuth'; - -const TEAM_HANDLES = new Set(['wesleyfinck.org', 'ronentk.me', 'pouriade.com']); - -const EARLY_TESTERS_HANDLES = new Set([ - 'bmann.ca', - 'tynanpurdy.com', - 'erlend.sh', - 'tgoerke.bsky.social', - 'psingletary.com', - 'hilarybaumann.com', - 'cosmik.network', - 'semble.so', - 'atproto.science', - 'chrisshank.com', - 'jasmine-pyz.bsky.social', - 'uppy-hacker.bsky.social', - 'joelchan86.bsky.social', -]); +import { + TEAM_HANDLES, + EARLY_TESTERS_HANDLES, +} from '../../shared/constants/featureFlags'; export async function getServerFeatureFlags() { const { user } = await getServerAuthStatus(); -- 2.51.2 From 262a5f9cdd33c189b048760c7ef19d2c179291d7 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 16:26:24 -0700 Subject: [PATCH 11/12] default to card collection activities only if connection feature flag --- .../containers/myFeedContainer/MyFeedContainer.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx index e7b28cf1..f6641427 100644 --- a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx +++ b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx @@ -20,6 +20,7 @@ import { UrlType, ActivitySource, ActivityType } from '@semble/types'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { CardSaveSource } from '@/features/analytics/types'; import { useState, useEffect } from 'react'; +import { useFeatureFlags } from '@/lib/clientFeatureFlags'; export default function MyFeedContainer() { const pathname = usePathname(); @@ -29,6 +30,8 @@ export default function MyFeedContainer() { const selectedFeed = (searchParams.get('feed') as 'global' | 'following') || 'global'; + const { data: featureFlags } = useFeatureFlags(); + // Parse activityTypes from URL params (can be multiple) const activityTypesParam = searchParams.getAll('activityTypes'); const selectedActivityTypes = @@ -38,15 +41,21 @@ export default function MyFeedContainer() { ) as ActivityType[]) : undefined; + // Hard-code to only CARD_COLLECTED when connections feature flag is false + const activityTypesFilter = + featureFlags?.connections === false + ? [ActivityType.CARD_COLLECTED] + : selectedActivityTypes; + const globalFeed = useGlobalFeed({ urlType: selectedUrlType, source: selectedSource, - activityTypes: selectedActivityTypes, + activityTypes: activityTypesFilter, }); const followingFeed = useFollowingFeed({ urlType: selectedUrlType, source: selectedSource, - activityTypes: selectedActivityTypes, + activityTypes: activityTypesFilter, enabled: selectedFeed === 'following', }); -- 2.51.2 From a173ec5e459065983a85616dcebec5e1b396b03f Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 19 Mar 2026 16:33:58 -0700 Subject: [PATCH 12/12] include lib stats on connections in feed and notifications --- .../queries/GetFollowingFeedUseCase.ts | 70 ++++++++-- .../useCases/queries/GetGlobalFeedUseCase.ts | 70 ++++++++-- .../queries/GetMyNotificationsUseCase.ts | 131 +++++++++++++++--- .../http/factories/UseCaseFactory.ts | 1 + 4 files changed, 234 insertions(+), 38 deletions(-) diff --git a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts index 992c45d6..c9f86c5f 100644 --- a/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts @@ -342,6 +342,15 @@ export class GetFollowingFeedUseCase } >(); let connectionCuratorProfiles = new Map(); + let connectionUrlStatsMap = new Map< + string, + { + urlLibraryCount: number; + urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; + } + >(); if (connectionActivities.length > 0) { // Get unique connection IDs @@ -422,6 +431,33 @@ export class GetFollowingFeedUseCase } connectionCuratorProfiles = connectionCuratorProfilesResult.value; + + // Fetch URL library info for connection URLs + const connectionUrls = Array.from( + new Set([ + ...Array.from(connectionDataMap.values()).map((c) => c.sourceUrl), + ...Array.from(connectionDataMap.values()).map((c) => c.targetUrl), + ]), + ); + + const connectionUrlLibraryInfoMap = + await this.cardQueryRepository.getBatchUrlLibraryInfo( + connectionUrls, + query.callingUserId, + ); + + // Build a map of URL to stats for easy lookup + connectionUrls.forEach((url) => { + const urlInfo = connectionUrlLibraryInfoMap.get(url); + if (urlInfo) { + connectionUrlStatsMap.set(url, { + urlLibraryCount: urlInfo.urlLibraryCount, + urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, + }); + } + }); } // Transform activities to FeedItem in chronological order @@ -519,16 +555,34 @@ export class GetFollowingFeedUseCase // Build UrlView for source and target // Extract metadata props to avoid the value object wrapper + const sourceUrlStats = connectionUrlStatsMap.get( + connectionData.sourceUrl, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + const targetUrlStats = connectionUrlStatsMap.get( + connectionData.targetUrl, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + 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, + urlLibraryCount: sourceUrlStats.urlLibraryCount, + urlInLibrary: sourceUrlStats.urlInLibrary, + urlConnectionCount: sourceUrlStats.urlConnectionCount, + urlIsConnected: sourceUrlStats.urlIsConnected, }; const targetUrlView = { @@ -537,10 +591,10 @@ export class GetFollowingFeedUseCase connectionData.targetUrlMetadata || { url: connectionData.targetUrl, }, - urlLibraryCount: 0, // TODO: Fetch from DB if needed - urlInLibrary: undefined, - urlConnectionCount: undefined, - urlIsConnected: undefined, + urlLibraryCount: targetUrlStats.urlLibraryCount, + urlInLibrary: targetUrlStats.urlInLibrary, + urlConnectionCount: targetUrlStats.urlConnectionCount, + urlIsConnected: targetUrlStats.urlIsConnected, }; feedItems.push({ diff --git a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts index 19aaca12..9cba2b60 100644 --- a/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts +++ b/src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts @@ -357,6 +357,15 @@ export class GetGlobalFeedUseCase } >(); let connectionCuratorProfiles = new Map(); + let connectionUrlStatsMap = new Map< + string, + { + urlLibraryCount: number; + urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; + } + >(); if (connectionActivities.length > 0) { // Get unique connection IDs @@ -437,6 +446,33 @@ export class GetGlobalFeedUseCase } connectionCuratorProfiles = connectionCuratorProfilesResult.value; + + // Fetch URL library info for connection URLs + const connectionUrls = Array.from( + new Set([ + ...Array.from(connectionDataMap.values()).map((c) => c.sourceUrl), + ...Array.from(connectionDataMap.values()).map((c) => c.targetUrl), + ]), + ); + + const connectionUrlLibraryInfoMap = + await this.cardQueryRepository.getBatchUrlLibraryInfo( + connectionUrls, + query.callingUserId, + ); + + // Build a map of URL to stats for easy lookup + connectionUrls.forEach((url) => { + const urlInfo = connectionUrlLibraryInfoMap.get(url); + if (urlInfo) { + connectionUrlStatsMap.set(url, { + urlLibraryCount: urlInfo.urlLibraryCount, + urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, + }); + } + }); } // Transform activities to FeedItem in chronological order @@ -543,16 +579,34 @@ export class GetGlobalFeedUseCase // Build UrlView for source and target // Extract metadata props to avoid the value object wrapper + const sourceUrlStats = connectionUrlStatsMap.get( + connectionData.sourceUrl, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + const targetUrlStats = connectionUrlStatsMap.get( + connectionData.targetUrl, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + 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, + urlLibraryCount: sourceUrlStats.urlLibraryCount, + urlInLibrary: sourceUrlStats.urlInLibrary, + urlConnectionCount: sourceUrlStats.urlConnectionCount, + urlIsConnected: sourceUrlStats.urlIsConnected, }; const targetUrlView = { @@ -561,10 +615,10 @@ export class GetGlobalFeedUseCase connectionData.targetUrlMetadata || { url: connectionData.targetUrl, }, - urlLibraryCount: 0, // TODO: Fetch from DB if needed - urlInLibrary: undefined, - urlConnectionCount: undefined, - urlIsConnected: undefined, + urlLibraryCount: targetUrlStats.urlLibraryCount, + urlInLibrary: targetUrlStats.urlInLibrary, + urlConnectionCount: targetUrlStats.urlConnectionCount, + urlIsConnected: targetUrlStats.urlIsConnected, }; feedItems.push({ diff --git a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts index e83ee0e8..73f47ac6 100644 --- a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts +++ b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts @@ -10,6 +10,7 @@ import { CollectionAccessType } from '../../../../cards/domain/Collection'; import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; import { IConnectionRepository } from '../../../../cards/domain/IConnectionRepository'; import { ConnectionId } from '../../../../cards/domain/value-objects/ConnectionId'; +import { ICardQueryRepository } from '../../../../cards/domain/ICardQueryRepository'; export interface GetMyNotificationsDTO { userId: string; @@ -52,6 +53,7 @@ export class GetMyNotificationsUseCase private notificationRepository: INotificationRepository, private profileService: IProfileService, private connectionRepository: IConnectionRepository, + private cardQueryRepository: ICardQueryRepository, ) {} async execute( @@ -121,6 +123,83 @@ export class GetMyNotificationsUseCase const profileMap = profileMapResult.value; + // Batch fetch all connections for connection notifications + const connectionNotifications = notifications.filter( + (n) => + (n as any).connectionId !== undefined && + n.type === 'USER_CONNECTED_YOUR_URL', + ); + + const connectionMap = new Map(); + const connectionUrlStatsMap = new Map< + string, + { + urlLibraryCount: number; + urlInLibrary?: boolean; + urlConnectionCount?: number; + urlIsConnected?: boolean; + } + >(); + + if (connectionNotifications.length > 0) { + const connectionIds: ConnectionId[] = []; + for (const notification of connectionNotifications) { + const metadata = notification as any; + const connectionIdResult = ConnectionId.createFromString( + metadata.connectionId, + ); + if (connectionIdResult.isOk()) { + connectionIds.push(connectionIdResult.value); + } + } + + if (connectionIds.length > 0) { + const connectionsResult = + await this.connectionRepository.findByIds(connectionIds); + if (connectionsResult.isOk()) { + const connections = connectionsResult.value; + // Build connection map + for (const connection of connections) { + connectionMap.set( + connection.connectionId.getStringValue(), + connection, + ); + } + + // Collect all unique URLs from connections + const connectionUrls = Array.from( + new Set( + connections + .filter((c) => c.source.url && c.target.url) + .flatMap((c) => [c.source.url!.value, c.target.url!.value]), + ), + ); + + // Fetch URL stats + if (connectionUrls.length > 0) { + const connectionUrlLibraryInfoMap = + await this.cardQueryRepository.getBatchUrlLibraryInfo( + connectionUrls, + request.userId, + ); + + // Build stats map + connectionUrls.forEach((url) => { + const urlInfo = connectionUrlLibraryInfoMap.get(url); + if (urlInfo) { + connectionUrlStatsMap.set(url, { + urlLibraryCount: urlInfo.urlLibraryCount, + urlInLibrary: urlInfo.urlInLibrary, + urlConnectionCount: urlInfo.urlConnectionCount, + urlIsConnected: urlInfo.urlIsConnected, + }); + } + }); + } + } + } + } + // Transform enriched notifications to DTOs const notificationItems: NotificationItemDTO[] = []; @@ -139,28 +218,36 @@ export class GetMyNotificationsUseCase metadata.connectionId !== undefined && notification.type === 'USER_CONNECTED_YOUR_URL' ) { - // Get the connection data - const connectionIdResult = ConnectionId.createFromString( - metadata.connectionId, - ); - if (connectionIdResult.isErr()) { - continue; // Skip if invalid connection ID - } - - const connectionResult = await this.connectionRepository.findById( - connectionIdResult.value, - ); - if (connectionResult.isErr() || !connectionResult.value) { + // Get the connection from the pre-fetched map + const connection = connectionMap.get(metadata.connectionId); + if (!connection) { continue; // Skip if connection not found } - const connection = connectionResult.value; - // Only handle URL connections if (!connection.source.url || !connection.target.url) { continue; // Skip if not both URLs } + // Get URL stats from the pre-fetched map + const sourceUrlStats = connectionUrlStatsMap.get( + connection.source.url.value, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + + const targetUrlStats = connectionUrlStatsMap.get( + connection.target.url.value, + ) || { + urlLibraryCount: 0, + urlInLibrary: undefined, + urlConnectionCount: undefined, + urlIsConnected: undefined, + }; + // Build connection notification item const connectionNotificationItem: NotificationItem = { id: notification.id, @@ -215,10 +302,10 @@ export class GetMyNotificationsUseCase isbn: meta.isbn, }; })(), - urlLibraryCount: 0, // TODO: Could fetch if needed - urlInLibrary: undefined, - urlConnectionCount: undefined, - urlIsConnected: undefined, + urlLibraryCount: sourceUrlStats.urlLibraryCount, + urlInLibrary: sourceUrlStats.urlInLibrary, + urlConnectionCount: sourceUrlStats.urlConnectionCount, + urlIsConnected: sourceUrlStats.urlIsConnected, }, target: { url: connection.target.url.value, @@ -250,10 +337,10 @@ export class GetMyNotificationsUseCase isbn: meta.isbn, }; })(), - urlLibraryCount: 0, // TODO: Could fetch if needed - urlInLibrary: undefined, - urlConnectionCount: undefined, - urlIsConnected: undefined, + urlLibraryCount: targetUrlStats.urlLibraryCount, + urlInLibrary: targetUrlStats.urlInLibrary, + urlConnectionCount: targetUrlStats.urlConnectionCount, + urlIsConnected: targetUrlStats.urlIsConnected, }, }, }; diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 0f2b76d5..03cf6241 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -490,6 +490,7 @@ export class UseCaseFactory { repositories.notificationRepository, services.profileService, repositories.connectionRepository, + repositories.cardQueryRepository, ), getUnreadNotificationCountUseCase: new GetUnreadNotificationCountUseCase( repositories.notificationRepository, -- 2.51.2