This repository has no description
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}