This repository has no description
0

Configure Feed

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

semble / src / modules / cards / tests / utils / InMemoryCollectionQueryRepository.ts
16 kB 477 lines
1import { 2 ICollectionQueryRepository, 3 CollectionQueryOptions, 4 CollectionQueryResultDTO, 5 CollectionContainingCardDTO, 6 CollectionForUrlRawDTO, 7 PaginatedQueryResult, 8 CollectionSortField, 9 SortOrder, 10 CollectionForUrlQueryOptions, 11 SearchCollectionsOptions, 12 GetOpenCollectionsWithContributorOptions, 13 CollectionContributorDTO, 14} from '../../domain/ICollectionQueryRepository'; 15import { Collection } from '../../domain/Collection'; 16import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; 17import { InMemoryCardRepository } from './InMemoryCardRepository'; 18 19export class InMemoryCollectionQueryRepository 20 implements ICollectionQueryRepository 21{ 22 constructor( 23 private collectionRepository: InMemoryCollectionRepository, 24 private cardRepository?: InMemoryCardRepository, 25 ) {} 26 27 async findByCreator( 28 curatorId: string, 29 options: CollectionQueryOptions, 30 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> { 31 try { 32 const allCollections = this.collectionRepository.getAllCollections(); 33 let creatorCollections = allCollections.filter( 34 (collection) => collection.authorId.value === curatorId, 35 ); 36 37 if (options.searchText && options.searchText.trim()) { 38 const searchTerm = options.searchText.trim().toLowerCase(); 39 creatorCollections = creatorCollections.filter((collection) => { 40 const nameMatch = collection.name.value 41 .toLowerCase() 42 .includes(searchTerm); 43 const descriptionMatch = 44 collection.description?.value.toLowerCase().includes(searchTerm) || 45 false; 46 return nameMatch || descriptionMatch; 47 }); 48 } 49 50 const sortedCollections = this.sortCollections( 51 creatorCollections, 52 options.sortBy, 53 options.sortOrder, 54 ); 55 56 const startIndex = (options.page - 1) * options.limit; 57 const endIndex = startIndex + options.limit; 58 const paginatedCollections = sortedCollections.slice( 59 startIndex, 60 endIndex, 61 ); 62 63 const items: CollectionQueryResultDTO[] = paginatedCollections.map( 64 (collection) => { 65 const collectionPublishedRecordId = collection.publishedRecordId; 66 return { 67 id: collection.collectionId.getStringValue(), 68 uri: collectionPublishedRecordId?.uri, 69 authorId: collection.authorId.value, 70 name: collection.name.value, 71 description: collection.description?.value, 72 accessType: collection.accessType, 73 cardCount: collection.cardCount, 74 createdAt: collection.createdAt, 75 updatedAt: collection.updatedAt, 76 }; 77 }, 78 ); 79 80 return { 81 items, 82 totalCount: creatorCollections.length, 83 hasMore: endIndex < creatorCollections.length, 84 }; 85 } catch (error) { 86 throw new Error( 87 `Failed to query collections: ${error instanceof Error ? error.message : String(error)}`, 88 ); 89 } 90 } 91 92 private sortCollections( 93 collections: Collection[], 94 sortBy: CollectionSortField, 95 sortOrder: SortOrder, 96 ): Collection[] { 97 const sorted = [...collections].sort((a, b) => { 98 let comparison = 0; 99 100 switch (sortBy) { 101 case CollectionSortField.NAME: 102 comparison = a.name.value.localeCompare(b.name.value); 103 break; 104 case CollectionSortField.CREATED_AT: 105 comparison = a.createdAt.getTime() - b.createdAt.getTime(); 106 break; 107 case CollectionSortField.UPDATED_AT: 108 comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 109 break; 110 case CollectionSortField.CARD_COUNT: 111 comparison = a.cardCount - b.cardCount; 112 break; 113 default: 114 comparison = 0; 115 } 116 117 return sortOrder === SortOrder.DESC ? -comparison : comparison; 118 }); 119 120 return sorted; 121 } 122 123 async getCollectionsContainingCardForUser( 124 cardId: string, 125 curatorId: string, 126 ): Promise<CollectionContainingCardDTO[]> { 127 try { 128 const allCollections = this.collectionRepository.getAllCollections(); 129 const creatorCollections = allCollections.filter( 130 (collection) => collection.authorId.value === curatorId, 131 ); 132 133 const collectionsWithCard = creatorCollections.filter((collection) => 134 collection.cardLinks.some( 135 (link) => link.cardId.getStringValue() === cardId, 136 ), 137 ); 138 139 const result: CollectionContainingCardDTO[] = collectionsWithCard.map( 140 (collection) => { 141 const collectionPublishedRecordId = collection.publishedRecordId; 142 return { 143 id: collection.collectionId.getStringValue(), 144 uri: collectionPublishedRecordId?.uri, 145 name: collection.name.value, 146 description: collection.description?.value, 147 }; 148 }, 149 ); 150 151 return result; 152 } catch (error) { 153 throw new Error( 154 `Failed to get collections containing card: ${error instanceof Error ? error.message : String(error)}`, 155 ); 156 } 157 } 158 159 async getCollectionsWithUrl( 160 url: string, 161 options: CollectionForUrlQueryOptions, 162 ): Promise<PaginatedQueryResult<CollectionForUrlRawDTO>> { 163 try { 164 if (!this.cardRepository) { 165 throw new Error( 166 'Card repository is required for getCollectionsWithUrl', 167 ); 168 } 169 170 const allCards = this.cardRepository.getAllCards(); 171 const cardsWithUrl = allCards.filter( 172 (card) => card.isUrlCard && card.url?.value === url, 173 ); 174 175 const cardIds = new Set( 176 cardsWithUrl.map((card) => card.cardId.getStringValue()), 177 ); 178 179 const allCollections = this.collectionRepository.getAllCollections(); 180 const collectionsWithUrl = allCollections.filter((collection) => 181 collection.cardLinks.some((link) => 182 cardIds.has(link.cardId.getStringValue()), 183 ), 184 ); 185 186 // Sort collections 187 const sortedCollections = this.sortCollections( 188 collectionsWithUrl, 189 options.sortBy, 190 options.sortOrder, 191 ); 192 193 // Apply pagination 194 const { page, limit } = options; 195 const startIndex = (page - 1) * limit; 196 const endIndex = startIndex + limit; 197 const paginatedCollections = sortedCollections.slice( 198 startIndex, 199 endIndex, 200 ); 201 202 const items: CollectionForUrlRawDTO[] = paginatedCollections.map( 203 (collection) => { 204 const collectionPublishedRecordId = collection.publishedRecordId; 205 return { 206 id: collection.collectionId.getStringValue(), 207 uri: collectionPublishedRecordId?.uri, 208 name: collection.name.value, 209 description: collection.description?.value, 210 accessType: collection.accessType, 211 authorId: collection.authorId.value, 212 }; 213 }, 214 ); 215 216 return { 217 items, 218 totalCount: sortedCollections.length, 219 hasMore: endIndex < sortedCollections.length, 220 }; 221 } catch (error) { 222 throw new Error( 223 `Failed to get collections with URL: ${error instanceof Error ? error.message : String(error)}`, 224 ); 225 } 226 } 227 228 async searchCollections( 229 options: SearchCollectionsOptions, 230 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> { 231 try { 232 let allCollections = this.collectionRepository.getAllCollections(); 233 234 // Apply author filter if provided 235 if (options.authorId) { 236 allCollections = allCollections.filter( 237 (collection) => collection.authorId.value === options.authorId, 238 ); 239 } 240 241 // Apply access type filter if provided 242 if (options.accessType) { 243 allCollections = allCollections.filter( 244 (collection) => collection.accessType === options.accessType, 245 ); 246 } 247 248 // Apply tokenized search if searchText is provided 249 if (options.searchText && options.searchText.trim()) { 250 const searchWords = options.searchText 251 .trim() 252 .toLowerCase() 253 .split(/\s+/); 254 255 allCollections = allCollections.filter((collection) => { 256 const nameText = collection.name.value.toLowerCase(); 257 const descriptionText = 258 collection.description?.value.toLowerCase() || ''; 259 const combinedText = `${nameText} ${descriptionText}`; 260 261 // All search words must be found (AND logic) 262 return searchWords.every((word) => combinedText.includes(word)); 263 }); 264 } 265 266 const sortedCollections = this.sortCollections( 267 allCollections, 268 options.sortBy, 269 options.sortOrder, 270 ); 271 272 const startIndex = (options.page - 1) * options.limit; 273 const endIndex = startIndex + options.limit; 274 const paginatedCollections = sortedCollections.slice( 275 startIndex, 276 endIndex, 277 ); 278 279 const items: CollectionQueryResultDTO[] = paginatedCollections.map( 280 (collection) => { 281 const collectionPublishedRecordId = collection.publishedRecordId; 282 return { 283 id: collection.collectionId.getStringValue(), 284 uri: collectionPublishedRecordId?.uri, 285 authorId: collection.authorId.value, 286 name: collection.name.value, 287 description: collection.description?.value, 288 accessType: collection.accessType, 289 cardCount: collection.cardCount, 290 createdAt: collection.createdAt, 291 updatedAt: collection.updatedAt, 292 }; 293 }, 294 ); 295 296 return { 297 items, 298 totalCount: allCollections.length, 299 hasMore: endIndex < allCollections.length, 300 }; 301 } catch (error) { 302 throw new Error( 303 `Failed to search collections: ${error instanceof Error ? error.message : String(error)}`, 304 ); 305 } 306 } 307 308 async getOpenCollectionsWithContributor( 309 options: GetOpenCollectionsWithContributorOptions, 310 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> { 311 try { 312 const allCollections = this.collectionRepository.getAllCollections(); 313 314 // Filter for collections where: 315 // 1. User has added cards (via cardLinks.addedBy) 316 // 2. User is NOT the author 317 // 3. Collection is OPEN 318 let contributedCollections = allCollections.filter((collection) => { 319 const hasContributed = collection.cardLinks.some( 320 (link) => link.addedBy.value === options.contributorId, 321 ); 322 const isNotAuthor = collection.authorId.value !== options.contributorId; 323 const isOpen = collection.accessType === 'OPEN'; 324 325 return hasContributed && isNotAuthor && isOpen; 326 }); 327 328 // Sort by most recent contribution first, then by the specified field 329 const sortedCollections = [...contributedCollections].sort((a, b) => { 330 // Get most recent contribution date for each collection 331 const aLastContribution = Math.max( 332 ...a.cardLinks 333 .filter((link) => link.addedBy.value === options.contributorId) 334 .map((link) => link.addedAt.getTime()), 335 ); 336 const bLastContribution = Math.max( 337 ...b.cardLinks 338 .filter((link) => link.addedBy.value === options.contributorId) 339 .map((link) => link.addedAt.getTime()), 340 ); 341 342 // Primary sort: by most recent contribution (DESC) 343 const contributionComparison = bLastContribution - aLastContribution; 344 if (contributionComparison !== 0) return contributionComparison; 345 346 // Secondary sort: by the specified field 347 let comparison = 0; 348 switch (options.sortBy) { 349 case CollectionSortField.NAME: 350 comparison = a.name.value.localeCompare(b.name.value); 351 break; 352 case CollectionSortField.CREATED_AT: 353 comparison = a.createdAt.getTime() - b.createdAt.getTime(); 354 break; 355 case CollectionSortField.UPDATED_AT: 356 comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 357 break; 358 case CollectionSortField.CARD_COUNT: 359 comparison = a.cardCount - b.cardCount; 360 break; 361 } 362 return options.sortOrder === SortOrder.DESC ? -comparison : comparison; 363 }); 364 365 const startIndex = (options.page - 1) * options.limit; 366 const endIndex = startIndex + options.limit; 367 const paginatedCollections = sortedCollections.slice( 368 startIndex, 369 endIndex, 370 ); 371 372 const items: CollectionQueryResultDTO[] = paginatedCollections.map( 373 (collection) => { 374 const collectionPublishedRecordId = collection.publishedRecordId; 375 return { 376 id: collection.collectionId.getStringValue(), 377 uri: collectionPublishedRecordId?.uri, 378 authorId: collection.authorId.value, 379 name: collection.name.value, 380 description: collection.description?.value, 381 accessType: collection.accessType, 382 cardCount: collection.cardCount, 383 createdAt: collection.createdAt, 384 updatedAt: collection.updatedAt, 385 }; 386 }, 387 ); 388 389 return { 390 items, 391 totalCount: sortedCollections.length, 392 hasMore: endIndex < sortedCollections.length, 393 }; 394 } catch (error) { 395 throw new Error( 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)}`, 470 ); 471 } 472 } 473 474 clear(): void { 475 // No separate state to clear 476 } 477}