This repository has no description
semble
/
src
/
modules
/
cards
/
infrastructure
/
repositories
/
DrizzleCollectionQueryRepository.ts
18 kB
581 lines
1import {
2 eq,
3 desc,
4 asc,
5 count,
6 sql,
7 or,
8 ilike,
9 and,
10 inArray,
11} from 'drizzle-orm';
12import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
13import {
14 ICollectionQueryRepository,
15 CollectionQueryOptions,
16 PaginatedQueryResult,
17 CollectionQueryResultDTO,
18 CollectionSortField,
19 SortOrder,
20 CollectionContainingCardDTO,
21 CollectionForUrlRawDTO,
22 CollectionForUrlQueryOptions,
23 SearchCollectionsOptions,
24 GetOpenCollectionsWithContributorOptions,
25 CollectionContributorDTO,
26} from '../../domain/ICollectionQueryRepository';
27import { collections, collectionCards } from './schema/collection.sql';
28import { publishedRecords } from './schema/publishedRecord.sql';
29import { cards } from './schema/card.sql';
30import { CollectionMapper } from './mappers/CollectionMapper';
31import { CardTypeEnum } from '../../domain/value-objects/CardType';
32
33export class DrizzleCollectionQueryRepository
34 implements ICollectionQueryRepository
35{
36 constructor(private db: PostgresJsDatabase) {}
37
38 async findByCreator(
39 curatorId: string,
40 options: CollectionQueryOptions,
41 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> {
42 try {
43 const { page, limit, sortBy, sortOrder, searchText } = options;
44 const offset = (page - 1) * limit;
45
46 // Build the sort order
47 const orderDirection = sortOrder === SortOrder.ASC ? asc : desc;
48
49 // Build where conditions
50 const whereConditions = [eq(collections.authorId, curatorId)];
51
52 // Add search condition if searchText is provided
53 if (searchText && searchText.trim()) {
54 const searchTerm = `%${searchText.trim()}%`;
55 whereConditions.push(
56 or(
57 ilike(collections.name, searchTerm),
58 ilike(collections.description, searchTerm),
59 )!,
60 );
61 }
62
63 // Simple query: get collections with their stored card counts and URIs
64 const collectionsQuery = this.db
65 .select({
66 id: collections.id,
67 name: collections.name,
68 description: collections.description,
69 accessType: collections.accessType,
70 createdAt: collections.createdAt,
71 updatedAt: collections.updatedAt,
72 authorId: collections.authorId,
73 cardCount: collections.cardCount,
74 uri: publishedRecords.uri,
75 })
76 .from(collections)
77 .leftJoin(
78 publishedRecords,
79 eq(collections.publishedRecordId, publishedRecords.id),
80 )
81 .where(
82 sql`${whereConditions.reduce((acc, condition, index) =>
83 index === 0 ? condition : sql`${acc} AND ${condition}`,
84 )}`,
85 )
86 .orderBy(orderDirection(this.getSortColumn(sortBy)))
87 .limit(limit)
88 .offset(offset);
89
90 const collectionsResult = await collectionsQuery;
91
92 // Get total count with same search conditions
93 const totalCountResult = await this.db
94 .select({ count: count() })
95 .from(collections)
96 .where(
97 sql`${whereConditions.reduce((acc, condition, index) =>
98 index === 0 ? condition : sql`${acc} AND ${condition}`,
99 )}`,
100 );
101
102 const totalCount = totalCountResult[0]?.count || 0;
103 const hasMore = offset + collectionsResult.length < totalCount;
104
105 // Map to DTOs
106 const items = collectionsResult.map((raw) =>
107 CollectionMapper.toQueryResult({
108 id: raw.id,
109 uri: raw.uri,
110 name: raw.name,
111 description: raw.description,
112 accessType: raw.accessType,
113 createdAt: raw.createdAt,
114 updatedAt: raw.updatedAt,
115 authorId: raw.authorId,
116 cardCount: raw.cardCount,
117 }),
118 );
119
120 return {
121 items,
122 totalCount,
123 hasMore,
124 };
125 } catch (error) {
126 console.error('Error in findByCreator:', error);
127 throw error;
128 }
129 }
130
131 async getCollectionsContainingCardForUser(
132 cardId: string,
133 curatorId: string,
134 ): Promise<CollectionContainingCardDTO[]> {
135 try {
136 // Find collections authored by this curator that contain this card
137 const collectionResults = await this.db
138 .select({
139 id: collections.id,
140 name: collections.name,
141 description: collections.description,
142 uri: publishedRecords.uri,
143 })
144 .from(collections)
145 .leftJoin(
146 publishedRecords,
147 eq(collections.publishedRecordId, publishedRecords.id),
148 )
149 .innerJoin(
150 collectionCards,
151 eq(collections.id, collectionCards.collectionId),
152 )
153 .where(
154 and(
155 eq(collections.authorId, curatorId),
156 eq(collectionCards.cardId, cardId),
157 ),
158 )
159 .orderBy(asc(collections.name));
160
161 return collectionResults.map((result) => ({
162 id: result.id,
163 uri: result.uri || undefined,
164 name: result.name,
165 description: result.description || undefined,
166 }));
167 } catch (error) {
168 console.error('Error in getCollectionsContainingCardForUser:', error);
169 throw error;
170 }
171 }
172
173 async getCollectionsWithUrl(
174 url: string,
175 options: CollectionForUrlQueryOptions,
176 ): Promise<PaginatedQueryResult<CollectionForUrlRawDTO>> {
177 try {
178 const { page, limit, sortBy, sortOrder } = options;
179 const offset = (page - 1) * limit;
180
181 // Build the sort order
182 const orderDirection = sortOrder === SortOrder.ASC ? asc : desc;
183
184 // Find all URL cards with this URL
185 const urlCardsQuery = this.db
186 .select({
187 id: cards.id,
188 })
189 .from(cards)
190 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL)));
191
192 const urlCardsResult = await urlCardsQuery;
193
194 if (urlCardsResult.length === 0) {
195 return {
196 items: [],
197 totalCount: 0,
198 hasMore: false,
199 };
200 }
201
202 const cardIds = urlCardsResult.map((card) => card.id);
203
204 // Find all collections that contain any of these cards with pagination and sorting
205 const collectionsQuery = this.db
206 .selectDistinct({
207 id: collections.id,
208 name: collections.name,
209 description: collections.description,
210 accessType: collections.accessType,
211 authorId: collections.authorId,
212 uri: publishedRecords.uri,
213 createdAt: collections.createdAt,
214 updatedAt: collections.updatedAt,
215 cardCount: collections.cardCount,
216 })
217 .from(collections)
218 .leftJoin(
219 publishedRecords,
220 eq(collections.publishedRecordId, publishedRecords.id),
221 )
222 .innerJoin(
223 collectionCards,
224 eq(collections.id, collectionCards.collectionId),
225 )
226 .where(inArray(collectionCards.cardId, cardIds))
227 .orderBy(orderDirection(this.getSortColumn(sortBy)))
228 .limit(limit)
229 .offset(offset);
230
231 const collectionsResult = await collectionsQuery;
232
233 // Get total count of distinct collections
234 const totalCountQuery = this.db
235 .selectDistinct({
236 id: collections.id,
237 })
238 .from(collections)
239 .innerJoin(
240 collectionCards,
241 eq(collections.id, collectionCards.collectionId),
242 )
243 .where(inArray(collectionCards.cardId, cardIds));
244
245 const totalCountResult = await totalCountQuery;
246 const totalCount = totalCountResult.length;
247 const hasMore = offset + collectionsResult.length < totalCount;
248
249 const items = collectionsResult.map((result) => ({
250 id: result.id,
251 uri: result.uri || undefined,
252 name: result.name,
253 description: result.description || undefined,
254 accessType: result.accessType,
255 authorId: result.authorId,
256 }));
257
258 return {
259 items,
260 totalCount,
261 hasMore,
262 };
263 } catch (error) {
264 console.error('Error in getCollectionsWithUrl:', error);
265 throw error;
266 }
267 }
268
269 async searchCollections(
270 options: SearchCollectionsOptions,
271 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> {
272 try {
273 const {
274 page,
275 limit,
276 sortBy,
277 sortOrder,
278 searchText,
279 authorId,
280 accessType,
281 } = options;
282 const offset = (page - 1) * limit;
283
284 // Build the sort order
285 const orderDirection = sortOrder === SortOrder.ASC ? asc : desc;
286
287 // Build where conditions
288 const whereConditions = [];
289
290 // Add author filter if provided
291 if (authorId) {
292 whereConditions.push(eq(collections.authorId, authorId));
293 }
294
295 // Add access type filter if provided
296 if (accessType) {
297 whereConditions.push(eq(collections.accessType, accessType));
298 }
299
300 // Add tokenized search condition if searchText is provided
301 if (searchText && searchText.trim()) {
302 const searchWords = searchText.trim().split(/\s+/);
303 const searchConditions = searchWords.map(
304 (word) =>
305 or(
306 ilike(collections.name, `%${word}%`),
307 ilike(collections.description, `%${word}%`),
308 )!,
309 );
310
311 // All words must be found (AND logic)
312 whereConditions.push(and(...searchConditions)!);
313 }
314
315 // Build the where clause
316 const whereClause =
317 whereConditions.length > 0
318 ? sql`${whereConditions.reduce((acc, condition, index) =>
319 index === 0 ? condition : sql`${acc} AND ${condition}`,
320 )}`
321 : sql`1=1`; // Always true when no conditions
322
323 // Query collections with their stored card counts and URIs
324 const collectionsQuery = this.db
325 .select({
326 id: collections.id,
327 name: collections.name,
328 description: collections.description,
329 accessType: collections.accessType,
330 createdAt: collections.createdAt,
331 updatedAt: collections.updatedAt,
332 authorId: collections.authorId,
333 cardCount: collections.cardCount,
334 uri: publishedRecords.uri,
335 })
336 .from(collections)
337 .leftJoin(
338 publishedRecords,
339 eq(collections.publishedRecordId, publishedRecords.id),
340 )
341 .where(whereClause)
342 .orderBy(orderDirection(this.getSortColumn(sortBy)))
343 .limit(limit)
344 .offset(offset);
345
346 const collectionsResult = await collectionsQuery;
347
348 // Get total count with same search conditions
349 const totalCountResult = await this.db
350 .select({ count: count() })
351 .from(collections)
352 .where(whereClause);
353
354 const totalCount = totalCountResult[0]?.count || 0;
355 const hasMore = offset + collectionsResult.length < totalCount;
356
357 // Map to DTOs
358 const items = collectionsResult.map((raw) =>
359 CollectionMapper.toQueryResult({
360 id: raw.id,
361 uri: raw.uri,
362 name: raw.name,
363 description: raw.description,
364 accessType: raw.accessType,
365 createdAt: raw.createdAt,
366 updatedAt: raw.updatedAt,
367 authorId: raw.authorId,
368 cardCount: raw.cardCount,
369 }),
370 );
371
372 return {
373 items,
374 totalCount,
375 hasMore,
376 };
377 } catch (error) {
378 console.error('Error in searchCollections:', error);
379 throw error;
380 }
381 }
382
383 async getOpenCollectionsWithContributor(
384 options: GetOpenCollectionsWithContributorOptions,
385 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> {
386 try {
387 const { contributorId, page, limit, sortBy, sortOrder } = options;
388 const offset = (page - 1) * limit;
389
390 // Build the sort order
391 const orderDirection = sortOrder === SortOrder.ASC ? asc : desc;
392
393 // Get collections where:
394 // 1. User has added cards (via collection_cards.addedBy)
395 // 2. User is NOT the author (collections.authorId != contributorId)
396 // 3. Collection is OPEN (collections.accessType = 'OPEN')
397 // Sort by most recent contribution (addedAt DESC) as primary sort
398
399 const collectionsQuery = this.db
400 .selectDistinct({
401 id: collections.id,
402 name: collections.name,
403 description: collections.description,
404 accessType: collections.accessType,
405 createdAt: collections.createdAt,
406 updatedAt: collections.updatedAt,
407 authorId: collections.authorId,
408 cardCount: collections.cardCount,
409 uri: publishedRecords.uri,
410 // Get the most recent contribution date for sorting
411 lastContributionDate: sql<Date>`MAX(${collectionCards.addedAt})`.as(
412 'last_contribution_date',
413 ),
414 })
415 .from(collections)
416 .leftJoin(
417 publishedRecords,
418 eq(collections.publishedRecordId, publishedRecords.id),
419 )
420 .innerJoin(
421 collectionCards,
422 eq(collections.id, collectionCards.collectionId),
423 )
424 .where(
425 and(
426 eq(collectionCards.addedBy, contributorId),
427 sql`${collections.authorId} != ${contributorId}`, // Not the author
428 eq(collections.accessType, 'OPEN'),
429 ),
430 )
431 .groupBy(
432 collections.id,
433 collections.name,
434 collections.description,
435 collections.accessType,
436 collections.createdAt,
437 collections.updatedAt,
438 collections.authorId,
439 collections.cardCount,
440 publishedRecords.uri,
441 )
442 .orderBy(
443 // Primary sort: by most recent contribution (addedAt DESC)
444 desc(sql`MAX(${collectionCards.addedAt})`),
445 // Secondary sort: by the specified field
446 orderDirection(this.getSortColumn(sortBy)),
447 )
448 .limit(limit)
449 .offset(offset);
450
451 const collectionsResult = await collectionsQuery;
452
453 // Get total count with same conditions
454 const totalCountQuery = this.db
455 .selectDistinct({
456 id: collections.id,
457 })
458 .from(collections)
459 .innerJoin(
460 collectionCards,
461 eq(collections.id, collectionCards.collectionId),
462 )
463 .where(
464 and(
465 eq(collectionCards.addedBy, contributorId),
466 sql`${collections.authorId} != ${contributorId}`,
467 eq(collections.accessType, 'OPEN'),
468 ),
469 );
470
471 const totalCountResult = await totalCountQuery;
472 const totalCount = totalCountResult.length;
473 const hasMore = offset + collectionsResult.length < totalCount;
474
475 // Map to DTOs
476 const items = collectionsResult.map((raw) =>
477 CollectionMapper.toQueryResult({
478 id: raw.id,
479 uri: raw.uri,
480 name: raw.name,
481 description: raw.description,
482 accessType: raw.accessType,
483 createdAt: raw.createdAt,
484 updatedAt: raw.updatedAt,
485 authorId: raw.authorId,
486 cardCount: raw.cardCount,
487 }),
488 );
489
490 return {
491 items,
492 totalCount,
493 hasMore,
494 };
495 } catch (error) {
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);
563 throw error;
564 }
565 }
566
567 private getSortColumn(sortBy: CollectionSortField) {
568 switch (sortBy) {
569 case CollectionSortField.NAME:
570 return collections.name;
571 case CollectionSortField.CREATED_AT:
572 return collections.createdAt;
573 case CollectionSortField.UPDATED_AT:
574 return collections.updatedAt;
575 case CollectionSortField.CARD_COUNT:
576 return collections.cardCount;
577 default:
578 return collections.name;
579 }
580 }
581}