This repository has no description
0

Configure Feed

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

get collection contributor query

+428
+168
src/modules/cards/application/useCases/queries/GetCollectionContributorsUseCase.ts
··· 1 + import { err, ok, Result } from 'src/shared/core/Result'; 2 + import { UseCase } from 'src/shared/core/UseCase'; 3 + import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; 4 + import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; 5 + import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; 6 + import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 7 + import { ContributorUser } from '@semble/types'; 8 + import { ProfileEnricher } from 'src/modules/cards/application/services/ProfileEnricher'; 9 + 10 + export interface GetCollectionContributorsQuery { 11 + collectionId: string; // Collection UUID 12 + callingUserId?: string; 13 + page?: number; 14 + limit?: number; 15 + } 16 + 17 + export interface GetCollectionContributorsResult { 18 + users: ContributorUser[]; 19 + pagination: { 20 + currentPage: number; 21 + totalPages: number; 22 + totalCount: number; 23 + hasMore: boolean; 24 + limit: number; 25 + }; 26 + } 27 + 28 + export class ValidationError extends Error { 29 + constructor(message: string) { 30 + super(message); 31 + this.name = 'ValidationError'; 32 + } 33 + } 34 + 35 + export class GetCollectionContributorsUseCase 36 + implements 37 + UseCase< 38 + GetCollectionContributorsQuery, 39 + Result<GetCollectionContributorsResult> 40 + > 41 + { 42 + constructor( 43 + private collectionQueryRepository: ICollectionQueryRepository, 44 + private profileService: IProfileService, 45 + private collectionRepository: ICollectionRepository, 46 + ) {} 47 + 48 + async execute( 49 + query: GetCollectionContributorsQuery, 50 + ): Promise<Result<GetCollectionContributorsResult>> { 51 + // Set defaults 52 + const page = query.page || 1; 53 + const limit = Math.min(query.limit || 20, 100); // Cap at 100 54 + 55 + // Validate collection ID 56 + const collectionIdResult = CollectionId.createFromString( 57 + query.collectionId, 58 + ); 59 + if (collectionIdResult.isErr()) { 60 + return err( 61 + new ValidationError( 62 + `Invalid collection ID: ${collectionIdResult.error.message}`, 63 + ), 64 + ); 65 + } 66 + 67 + try { 68 + // Verify collection exists 69 + const collectionResult = await this.collectionRepository.findById( 70 + collectionIdResult.value, 71 + ); 72 + if (collectionResult.isErr()) { 73 + return err( 74 + new Error( 75 + `Failed to fetch collection: ${collectionResult.error instanceof Error ? collectionResult.error.message : 'Unknown error'}`, 76 + ), 77 + ); 78 + } 79 + if (!collectionResult.value) { 80 + return err(new ValidationError('Collection not found')); 81 + } 82 + 83 + const collection = collectionResult.value; 84 + const authorId = collection.authorId.value; 85 + 86 + // Get contributors from repository (excluding author) 87 + const contributorsResult = 88 + await this.collectionQueryRepository.getCollectionContributors( 89 + query.collectionId, 90 + authorId, 91 + { page, limit }, 92 + ); 93 + 94 + const totalCount = contributorsResult.totalCount; 95 + 96 + // Extract unique contributor IDs 97 + const uniqueContributorIds = contributorsResult.items.map( 98 + (item) => item.userId, 99 + ); 100 + 101 + if (uniqueContributorIds.length === 0) { 102 + return ok({ 103 + users: [], 104 + pagination: { 105 + currentPage: page, 106 + totalPages: 0, 107 + totalCount: 0, 108 + hasMore: false, 109 + limit, 110 + }, 111 + }); 112 + } 113 + 114 + // Fetch profiles for all contributors using ProfileEnricher 115 + const profileEnricher = new ProfileEnricher(this.profileService); 116 + const profileMapResult = await profileEnricher.buildProfileMap( 117 + uniqueContributorIds, 118 + query.callingUserId, 119 + { 120 + skipFailures: false, // Fail if any profile fetch fails 121 + mapToUser: true, // Use full User DTO with isFollowing 122 + }, 123 + ); 124 + 125 + if (profileMapResult.isErr()) { 126 + return err( 127 + new Error( 128 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 129 + ), 130 + ); 131 + } 132 + 133 + const profileMap = profileMapResult.value; 134 + 135 + // Build users array in the order of contributors (chronological by most recent contribution) 136 + // Add contributionCount to each user 137 + const users: ContributorUser[] = contributorsResult.items 138 + .map((item) => { 139 + const user = profileMap.get(item.userId); 140 + if (!user) { 141 + return undefined; 142 + } 143 + return { 144 + ...user, 145 + contributionCount: item.contributionCount, 146 + }; 147 + }) 148 + .filter((user): user is ContributorUser => user !== undefined); 149 + 150 + return ok({ 151 + users, 152 + pagination: { 153 + currentPage: page, 154 + totalPages: Math.ceil(totalCount / limit), 155 + totalCount, 156 + hasMore: page * limit < totalCount, 157 + limit, 158 + }, 159 + }); 160 + } catch (error) { 161 + return err( 162 + new Error( 163 + `Failed to retrieve collection contributors: ${error instanceof Error ? error.message : 'Unknown error'}`, 164 + ), 165 + ); 166 + } 167 + } 168 + }
+12
src/modules/cards/domain/ICollectionQueryRepository.ts
··· 93 93 sortOrder: SortOrder; 94 94 } 95 95 96 + export interface CollectionContributorDTO { 97 + userId: string; 98 + contributionCount: number; 99 + lastContributedAt: Date; 100 + } 101 + 96 102 export interface ICollectionQueryRepository { 97 103 findByCreator( 98 104 curatorId: string, ··· 116 122 getOpenCollectionsWithContributor( 117 123 options: GetOpenCollectionsWithContributorOptions, 118 124 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>>; 125 + 126 + getCollectionContributors( 127 + collectionId: string, 128 + authorId: string, 129 + options: { page: number; limit: number }, 130 + ): Promise<PaginatedQueryResult<CollectionContributorDTO>>; 119 131 }
+38
src/modules/cards/infrastructure/http/controllers/GetCollectionContributorsController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Request, Response } from 'express'; 3 + import { GetCollectionContributorsUseCase } from '../../../application/useCases/queries/GetCollectionContributorsUseCase'; 4 + 5 + export class GetCollectionContributorsController extends Controller { 6 + constructor( 7 + private getCollectionContributorsUseCase: GetCollectionContributorsUseCase, 8 + ) { 9 + super(); 10 + } 11 + 12 + async executeImpl(req: Request, res: Response): Promise<any> { 13 + try { 14 + const { collectionId } = req.params; 15 + const { page, limit } = req.query; 16 + const callingUserId = (req as any).did; 17 + 18 + if (!collectionId) { 19 + return this.fail(res, 'Collection ID is required'); 20 + } 21 + 22 + const result = await this.getCollectionContributorsUseCase.execute({ 23 + collectionId, 24 + callingUserId, 25 + page: page ? parseInt(page as string) : undefined, 26 + limit: limit ? parseInt(limit as string) : undefined, 27 + }); 28 + 29 + if (result.isErr()) { 30 + return this.fail(res, result.error); 31 + } 32 + 33 + return this.ok(res, result.value); 34 + } catch (error: any) { 35 + return this.fail(res, error); 36 + } 37 + } 38 + }
+9
src/modules/cards/infrastructure/http/routes/collectionRoutes.ts
··· 11 11 import { GetOpenCollectionsWithContributorController } from '../controllers/GetOpenCollectionsWithContributorController'; 12 12 import { GetCollectionFollowersController } from '../controllers/GetCollectionFollowersController'; 13 13 import { GetCollectionFollowersCountController } from '../controllers/GetCollectionFollowersCountController'; 14 + import { GetCollectionContributorsController } from '../controllers/GetCollectionContributorsController'; 14 15 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 15 16 16 17 export function createCollectionRoutes( ··· 27 28 getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, 28 29 getCollectionFollowersController: GetCollectionFollowersController, 29 30 getCollectionFollowersCountController: GetCollectionFollowersCountController, 31 + getCollectionContributorsController: GetCollectionContributorsController, 30 32 ): Router { 31 33 const router = Router(); 32 34 ··· 77 79 '/:collectionId/followers/count', 78 80 authMiddleware.optionalAuth(), 79 81 (req, res) => getCollectionFollowersCountController.execute(req, res), 82 + ); 83 + 84 + // GET /api/collections/:collectionId/contributors - Get collection contributors 85 + router.get( 86 + '/:collectionId/contributors', 87 + authMiddleware.optionalAuth(), 88 + (req, res) => getCollectionContributorsController.execute(req, res), 80 89 ); 81 90 82 91 // GET /api/collections/:collectionId - Get collection page
+3
src/modules/cards/infrastructure/http/routes/index.ts
··· 29 29 import { GetCollectionsForUrlController } from '../controllers/GetCollectionsForUrlController'; 30 30 import { GetCollectionFollowersController } from '../controllers/GetCollectionFollowersController'; 31 31 import { GetCollectionFollowersCountController } from '../controllers/GetCollectionFollowersCountController'; 32 + import { GetCollectionContributorsController } from '../controllers/GetCollectionContributorsController'; 32 33 33 34 export function createCardsModuleRoutes( 34 35 authMiddleware: AuthMiddleware, ··· 61 62 getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, 62 63 getCollectionFollowersController: GetCollectionFollowersController, 63 64 getCollectionFollowersCountController: GetCollectionFollowersCountController, 65 + getCollectionContributorsController: GetCollectionContributorsController, 64 66 ): Router { 65 67 const router = Router(); 66 68 ··· 104 106 getOpenCollectionsWithContributorController, 105 107 getCollectionFollowersController, 106 108 getCollectionFollowersCountController, 109 + getCollectionContributorsController, 107 110 ), 108 111 ); 109 112
+67
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 22 22 CollectionForUrlQueryOptions, 23 23 SearchCollectionsOptions, 24 24 GetOpenCollectionsWithContributorOptions, 25 + CollectionContributorDTO, 25 26 } from '../../domain/ICollectionQueryRepository'; 26 27 import { collections, collectionCards } from './schema/collection.sql'; 27 28 import { publishedRecords } from './schema/publishedRecord.sql'; ··· 493 494 }; 494 495 } catch (error) { 495 496 console.error('Error in getOpenCollectionsWithContributor:', error); 497 + throw error; 498 + } 499 + } 500 + 501 + async getCollectionContributors( 502 + collectionId: string, 503 + authorId: string, 504 + options: { page: number; limit: number }, 505 + ): Promise<PaginatedQueryResult<CollectionContributorDTO>> { 506 + try { 507 + const { page, limit } = options; 508 + const offset = (page - 1) * limit; 509 + 510 + // Get unique contributors with their contribution count and last contribution time 511 + // Exclude the collection author 512 + const contributorsQuery = this.db 513 + .select({ 514 + userId: collectionCards.addedBy, 515 + contributionCount: sql<number>`CAST(COUNT(*) AS INTEGER)`.as( 516 + 'contribution_count', 517 + ), 518 + lastContributedAt: sql<Date>`MAX(${collectionCards.addedAt})`.as( 519 + 'last_contributed_at', 520 + ), 521 + }) 522 + .from(collectionCards) 523 + .where( 524 + and( 525 + eq(collectionCards.collectionId, collectionId), 526 + sql`${collectionCards.addedBy} != ${authorId}`, // Exclude author 527 + ), 528 + ) 529 + .groupBy(collectionCards.addedBy) 530 + .orderBy(sql`MAX(${collectionCards.addedAt}) DESC`) // Most recent contribution first 531 + .limit(limit) 532 + .offset(offset); 533 + 534 + const contributors = await contributorsQuery; 535 + 536 + // Get total count of distinct contributors (excluding author) 537 + const countQuery = this.db 538 + .select({ 539 + count: sql<number>`COUNT(DISTINCT ${collectionCards.addedBy})`, 540 + }) 541 + .from(collectionCards) 542 + .where( 543 + and( 544 + eq(collectionCards.collectionId, collectionId), 545 + sql`${collectionCards.addedBy} != ${authorId}`, 546 + ), 547 + ); 548 + 549 + const countResult = await countQuery; 550 + const totalCount = countResult[0]?.count || 0; 551 + 552 + return { 553 + items: contributors.map((c) => ({ 554 + userId: c.userId, 555 + contributionCount: c.contributionCount, 556 + lastContributedAt: c.lastContributedAt, 557 + })), 558 + totalCount, 559 + hasMore: page * limit < totalCount, 560 + }; 561 + } catch (error) { 562 + console.error('Error in getCollectionContributors:', error); 496 563 throw error; 497 564 } 498 565 }
+1
src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts
··· 785 785 .mockRejectedValue(new Error('Database error')), 786 786 searchCollections: jest.fn(), 787 787 getOpenCollectionsWithContributor: jest.fn(), 788 + getCollectionContributors: jest.fn(), 788 789 }; 789 790 790 791 const errorUseCase = new GetCollectionsForUrlUseCase(
+1
src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts
··· 597 597 getCollectionsWithUrl: jest.fn(), 598 598 searchCollections: jest.fn(), 599 599 getOpenCollectionsWithContributor: jest.fn(), 600 + getCollectionContributors: jest.fn(), 600 601 }; 601 602 602 603 const errorUseCase = new GetUrlStatusForMyLibraryUseCase(
+74
src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts
··· 10 10 CollectionForUrlQueryOptions, 11 11 SearchCollectionsOptions, 12 12 GetOpenCollectionsWithContributorOptions, 13 + CollectionContributorDTO, 13 14 } from '../../domain/ICollectionQueryRepository'; 14 15 import { Collection } from '../../domain/Collection'; 15 16 import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; ··· 393 394 } catch (error) { 394 395 throw new Error( 395 396 `Failed to get open collections with contributor: ${error instanceof Error ? error.message : String(error)}`, 397 + ); 398 + } 399 + } 400 + 401 + async getCollectionContributors( 402 + collectionId: string, 403 + authorId: string, 404 + options: { page: number; limit: number }, 405 + ): Promise<PaginatedQueryResult<CollectionContributorDTO>> { 406 + try { 407 + const allCollections = this.collectionRepository.getAllCollections(); 408 + const collection = allCollections.find( 409 + (c) => c.collectionId.getStringValue() === collectionId, 410 + ); 411 + 412 + if (!collection) { 413 + return { 414 + items: [], 415 + totalCount: 0, 416 + hasMore: false, 417 + }; 418 + } 419 + 420 + // Get unique contributors (excluding author) with their contribution counts 421 + const contributorMap = new Map< 422 + string, 423 + { userId: string; contributionCount: number; lastContributedAt: Date } 424 + >(); 425 + 426 + for (const link of collection.cardLinks) { 427 + const contributorId = link.addedBy.value; 428 + 429 + // Skip the collection author 430 + if (contributorId === authorId) { 431 + continue; 432 + } 433 + 434 + if (contributorMap.has(contributorId)) { 435 + const existing = contributorMap.get(contributorId)!; 436 + existing.contributionCount++; 437 + if (link.addedAt > existing.lastContributedAt) { 438 + existing.lastContributedAt = link.addedAt; 439 + } 440 + } else { 441 + contributorMap.set(contributorId, { 442 + userId: contributorId, 443 + contributionCount: 1, 444 + lastContributedAt: link.addedAt, 445 + }); 446 + } 447 + } 448 + 449 + // Convert to array and sort by most recent contribution 450 + let contributors = Array.from(contributorMap.values()).sort( 451 + (a, b) => b.lastContributedAt.getTime() - a.lastContributedAt.getTime(), 452 + ); 453 + 454 + const totalCount = contributors.length; 455 + 456 + // Apply pagination 457 + const { page, limit } = options; 458 + const startIndex = (page - 1) * limit; 459 + const endIndex = startIndex + limit; 460 + contributors = contributors.slice(startIndex, endIndex); 461 + 462 + return { 463 + items: contributors, 464 + totalCount, 465 + hasMore: endIndex < totalCount, 466 + }; 467 + } catch (error) { 468 + throw new Error( 469 + `Failed to get collection contributors: ${error instanceof Error ? error.message : String(error)}`, 396 470 ); 397 471 } 398 472 }
+1
src/shared/infrastructure/http/app.ts
··· 126 126 controllers.getOpenCollectionsWithContributorController, 127 127 controllers.getCollectionFollowersController, 128 128 controllers.getCollectionFollowersCountController, 129 + controllers.getCollectionContributorsController, 129 130 ); 130 131 131 132 const feedRouter = createFeedRoutes(
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 54 54 import { GetFollowingCollectionsCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCollectionsCountController'; 55 55 import { GetCollectionFollowersController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersController'; 56 56 import { GetCollectionFollowersCountController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController'; 57 + import { GetCollectionContributorsController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionContributorsController'; 57 58 import { CookieService } from '../services/CookieService'; 58 59 59 60 export interface Controllers { ··· 102 103 getNoteCardsForUrlController: GetNoteCardsForUrlController; 103 104 getCollectionFollowersController: GetCollectionFollowersController; 104 105 getCollectionFollowersCountController: GetCollectionFollowersCountController; 106 + getCollectionContributorsController: GetCollectionContributorsController; 105 107 // Feed controllers 106 108 getGlobalFeedController: GetGlobalFeedController; 107 109 getGemActivityFeedController: GetGemActivityFeedController; ··· 264 266 getCollectionFollowersCountController: 265 267 new GetCollectionFollowersCountController( 266 268 useCases.getCollectionFollowersCountUseCase, 269 + ), 270 + getCollectionContributorsController: 271 + new GetCollectionContributorsController( 272 + useCases.getCollectionContributorsUseCase, 267 273 ), 268 274 269 275 // Feed controllers
+7
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 64 64 import { GetFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowersCountUseCase'; 65 65 import { GetFollowingCollectionsCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase'; 66 66 import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase'; 67 + import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase'; 67 68 68 69 export interface WorkerUseCases { 69 70 addActivityToFeedUseCase: AddActivityToFeedUseCase; ··· 106 107 getFollowersCountUseCase: GetFollowersCountUseCase; 107 108 getFollowingCollectionsCountUseCase: GetFollowingCollectionsCountUseCase; 108 109 getCollectionFollowersCountUseCase: GetCollectionFollowersCountUseCase; 110 + getCollectionContributorsUseCase: GetCollectionContributorsUseCase; 109 111 // Card use cases 110 112 addUrlToLibraryUseCase: AddUrlToLibraryUseCase; 111 113 addCardToLibraryUseCase: AddCardToLibraryUseCase; ··· 245 247 repositories.followsRepository, 246 248 repositories.collectionRepository, 247 249 ), 250 + getCollectionContributorsUseCase: new GetCollectionContributorsUseCase( 251 + repositories.collectionQueryRepository, 252 + services.profileService, 253 + repositories.collectionRepository, 254 + ), 248 255 249 256 // Card use cases 250 257 addUrlToLibraryUseCase: new AddUrlToLibraryUseCase(
+5
src/types/src/api/common.ts
··· 13 13 followedCollectionsCount?: number; // Number of collections this user follows 14 14 } 15 15 16 + // Extended User interface for contributors with contribution count 17 + export interface ContributorUser extends User { 18 + contributionCount: number; // Number of cards this user contributed to the collection 19 + } 20 + 16 21 // Type alias for inline profile objects (without isFollowing) 17 22 // Used for nested author objects in collections, cards, etc. 18 23 export type UserProfileDTO = Omit<User, 'isFollowing'>;
+4
src/types/src/api/requests.ts
··· 292 292 export interface GetCollectionFollowersCountParams { 293 293 collectionId: string; // Collection UUID 294 294 } 295 + 296 + export interface GetCollectionContributorsParams extends PaginationParams { 297 + collectionId: string; // Collection UUID 298 + }
+7
src/types/src/api/responses.ts
··· 1 1 import { 2 2 User, 3 + ContributorUser, 3 4 Pagination, 4 5 CardSorting, 5 6 CollectionSorting, ··· 401 402 export interface GetFollowCountResponse { 402 403 count: number; 403 404 } 405 + 406 + // Contributor response types 407 + export interface GetCollectionContributorsResponse { 408 + users: ContributorUser[]; 409 + pagination: Pagination; 410 + }
+8
src/webapp/api-client/ApiClient.ts
··· 98 98 GetFollowingCollectionsCountParams, 99 99 GetCollectionFollowersCountParams, 100 100 GetFollowCountResponse, 101 + GetCollectionContributorsParams, 102 + GetCollectionContributorsResponse, 101 103 } from '@semble/types'; 102 104 103 105 // Main API Client class using composition ··· 286 288 params: GetCollectionFollowersCountParams, 287 289 ): Promise<GetFollowCountResponse> { 288 290 return this.queryClient.getCollectionFollowersCount(params); 291 + } 292 + 293 + async getCollectionContributors( 294 + params: GetCollectionContributorsParams, 295 + ): Promise<GetCollectionContributorsResponse> { 296 + return this.queryClient.getCollectionContributors(params); 289 297 } 290 298 291 299 // Card operations - delegate to CardClient
+17
src/webapp/api-client/clients/QueryClient.ts
··· 46 46 GetFollowingCollectionsCountParams, 47 47 GetCollectionFollowersCountParams, 48 48 GetFollowCountResponse, 49 + GetCollectionContributorsParams, 50 + GetCollectionContributorsResponse, 49 51 } from '@semble/types'; 50 52 51 53 export class QueryClient extends BaseClient { ··· 450 452 'GET', 451 453 `/api/collections/${params.collectionId}/followers/count`, 452 454 ); 455 + } 456 + 457 + async getCollectionContributors( 458 + params: GetCollectionContributorsParams, 459 + ): Promise<GetCollectionContributorsResponse> { 460 + const searchParams = new URLSearchParams(); 461 + if (params.page) searchParams.set('page', params.page.toString()); 462 + if (params.limit) searchParams.set('limit', params.limit.toString()); 463 + 464 + const queryString = searchParams.toString(); 465 + const endpoint = queryString 466 + ? `/api/collections/${params.collectionId}/contributors?${queryString}` 467 + : `/api/collections/${params.collectionId}/contributors`; 468 + 469 + return this.request<GetCollectionContributorsResponse>('GET', endpoint); 453 470 } 454 471 }