import { eq, desc, asc, count, sql, or, ilike, and, inArray, } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { ICollectionQueryRepository, CollectionQueryOptions, PaginatedQueryResult, CollectionQueryResultDTO, CollectionSortField, SortOrder, CollectionContainingCardDTO, CollectionForUrlRawDTO, CollectionForUrlQueryOptions, SearchCollectionsOptions, GetOpenCollectionsWithContributorOptions, CollectionContributorDTO, } from '../../domain/ICollectionQueryRepository'; import { collections, collectionCards } from './schema/collection.sql'; import { publishedRecords } from './schema/publishedRecord.sql'; import { cards } from './schema/card.sql'; import { CollectionMapper } from './mappers/CollectionMapper'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; export class DrizzleCollectionQueryRepository implements ICollectionQueryRepository { constructor(private db: PostgresJsDatabase) {} async findByCreator( curatorId: string, options: CollectionQueryOptions, ): Promise> { try { const { page, limit, sortBy, sortOrder, searchText } = options; const offset = (page - 1) * limit; // Build the sort order const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; // Build where conditions const whereConditions = [eq(collections.authorId, curatorId)]; // Add search condition if searchText is provided if (searchText && searchText.trim()) { const searchTerm = `%${searchText.trim()}%`; whereConditions.push( or( ilike(collections.name, searchTerm), ilike(collections.description, searchTerm), )!, ); } // Simple query: get collections with their stored card counts and URIs const collectionsQuery = this.db .select({ id: collections.id, name: collections.name, description: collections.description, accessType: collections.accessType, createdAt: collections.createdAt, updatedAt: collections.updatedAt, authorId: collections.authorId, cardCount: collections.cardCount, uri: publishedRecords.uri, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .where( sql`${whereConditions.reduce((acc, condition, index) => index === 0 ? condition : sql`${acc} AND ${condition}`, )}`, ) .orderBy(orderDirection(this.getSortColumn(sortBy))) .limit(limit) .offset(offset); const collectionsResult = await collectionsQuery; // Get total count with same search conditions const totalCountResult = await this.db .select({ count: count() }) .from(collections) .where( sql`${whereConditions.reduce((acc, condition, index) => index === 0 ? condition : sql`${acc} AND ${condition}`, )}`, ); const totalCount = totalCountResult[0]?.count || 0; const hasMore = offset + collectionsResult.length < totalCount; // Map to DTOs const items = collectionsResult.map((raw) => CollectionMapper.toQueryResult({ id: raw.id, uri: raw.uri, name: raw.name, description: raw.description, accessType: raw.accessType, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, cardCount: raw.cardCount, }), ); return { items, totalCount, hasMore, }; } catch (error) { console.error('Error in findByCreator:', error); throw error; } } async getCollectionsContainingCardForUser( cardId: string, curatorId: string, ): Promise { try { // Find all collections where this user added this card // Includes collections owned by the user AND open collections where they added the card const collectionResults = await this.db .select({ id: collections.id, name: collections.name, description: collections.description, uri: publishedRecords.uri, }) .from(collectionCards) .innerJoin( collections, eq(collectionCards.collectionId, collections.id), ) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .where( and( eq(collectionCards.cardId, cardId), eq(collectionCards.addedBy, curatorId), ), ) .orderBy(asc(collections.name)); return collectionResults.map((result) => ({ id: result.id, uri: result.uri || undefined, name: result.name, description: result.description || undefined, })); } catch (error) { console.error('Error in getCollectionsContainingCardForUser:', error); throw error; } } async getCollectionsWithUrl( url: string, options: CollectionForUrlQueryOptions, ): Promise> { try { const { page, limit, sortBy, sortOrder } = options; const offset = (page - 1) * limit; // Build the sort order const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; // Find all URL cards with this URL const urlCardsQuery = this.db .select({ id: cards.id, }) .from(cards) .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))); const urlCardsResult = await urlCardsQuery; if (urlCardsResult.length === 0) { return { items: [], totalCount: 0, hasMore: false, }; } const cardIds = urlCardsResult.map((card) => card.id); // Find all collections that contain any of these cards with pagination and sorting const collectionsQuery = this.db .select({ id: collections.id, name: collections.name, description: collections.description, accessType: collections.accessType, authorId: collections.authorId, uri: publishedRecords.uri, createdAt: collections.createdAt, updatedAt: collections.updatedAt, cardCount: collections.cardCount, addedAt: sql`MAX(${collectionCards.addedAt})`.as('added_at'), }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where(inArray(collectionCards.cardId, cardIds)) .groupBy( collections.id, collections.name, collections.description, collections.accessType, collections.authorId, collections.createdAt, collections.updatedAt, collections.cardCount, publishedRecords.uri, ) .orderBy( sortBy === CollectionSortField.ADDED_AT ? orderDirection(sql`MAX(${collectionCards.addedAt})`) : orderDirection(this.getSortColumn(sortBy)), ) .limit(limit) .offset(offset); const collectionsResult = await collectionsQuery; // Get total count of distinct collections const totalCountQuery = this.db .selectDistinct({ id: collections.id, }) .from(collections) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where(inArray(collectionCards.cardId, cardIds)); const totalCountResult = await totalCountQuery; const totalCount = totalCountResult.length; const hasMore = offset + collectionsResult.length < totalCount; const items = collectionsResult.map((result) => ({ id: result.id, uri: result.uri || undefined, name: result.name, description: result.description || undefined, accessType: result.accessType, authorId: result.authorId, })); return { items, totalCount, hasMore, }; } catch (error) { console.error('Error in getCollectionsWithUrl:', error); throw error; } } async searchCollections( options: SearchCollectionsOptions, ): Promise> { try { const { page, limit, sortBy, sortOrder, searchText, authorId, accessType, } = options; const offset = (page - 1) * limit; // Build the sort order const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; // Build where conditions const whereConditions = []; // Add author filter if provided if (authorId) { whereConditions.push(eq(collections.authorId, authorId)); } // Add access type filter if provided if (accessType) { whereConditions.push(eq(collections.accessType, accessType)); } // Add tokenized search condition if searchText is provided if (searchText && searchText.trim()) { const searchWords = searchText.trim().split(/\s+/); const searchConditions = searchWords.map( (word) => or( ilike(collections.name, `%${word}%`), ilike(collections.description, `%${word}%`), )!, ); // All words must be found (AND logic) whereConditions.push(and(...searchConditions)!); } // Build the where clause const whereClause = whereConditions.length > 0 ? sql`${whereConditions.reduce((acc, condition, index) => index === 0 ? condition : sql`${acc} AND ${condition}`, )}` : sql`1=1`; // Always true when no conditions // Query collections with their stored card counts and URIs const collectionsQuery = this.db .select({ id: collections.id, name: collections.name, description: collections.description, accessType: collections.accessType, createdAt: collections.createdAt, updatedAt: collections.updatedAt, authorId: collections.authorId, cardCount: collections.cardCount, uri: publishedRecords.uri, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .where(whereClause) .orderBy(orderDirection(this.getSortColumn(sortBy))) .limit(limit) .offset(offset); const collectionsResult = await collectionsQuery; // Get total count with same search conditions const totalCountResult = await this.db .select({ count: count() }) .from(collections) .where(whereClause); const totalCount = totalCountResult[0]?.count || 0; const hasMore = offset + collectionsResult.length < totalCount; // Map to DTOs const items = collectionsResult.map((raw) => CollectionMapper.toQueryResult({ id: raw.id, uri: raw.uri, name: raw.name, description: raw.description, accessType: raw.accessType, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, cardCount: raw.cardCount, }), ); return { items, totalCount, hasMore, }; } catch (error) { console.error('Error in searchCollections:', error); throw error; } } async getOpenCollectionsWithContributor( options: GetOpenCollectionsWithContributorOptions, ): Promise> { try { const { contributorId, page, limit, sortBy, sortOrder } = options; const offset = (page - 1) * limit; // Build the sort order const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; // Get collections where: // 1. User has added cards (via collection_cards.addedBy) // 2. User is NOT the author (collections.authorId != contributorId) // 3. Collection is OPEN (collections.accessType = 'OPEN') // Sort by most recent contribution (addedAt DESC) as primary sort const collectionsQuery = this.db .selectDistinct({ id: collections.id, name: collections.name, description: collections.description, accessType: collections.accessType, createdAt: collections.createdAt, updatedAt: collections.updatedAt, authorId: collections.authorId, cardCount: collections.cardCount, uri: publishedRecords.uri, // Get the most recent contribution date for sorting lastContributionDate: sql`MAX(${collectionCards.addedAt})`.as( 'last_contribution_date', ), }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where( and( eq(collectionCards.addedBy, contributorId), sql`${collections.authorId} != ${contributorId}`, // Not the author eq(collections.accessType, 'OPEN'), ), ) .groupBy( collections.id, collections.name, collections.description, collections.accessType, collections.createdAt, collections.updatedAt, collections.authorId, collections.cardCount, publishedRecords.uri, ) .orderBy( // Primary sort: by most recent contribution (addedAt DESC) desc(sql`MAX(${collectionCards.addedAt})`), // Secondary sort: by the specified field orderDirection(this.getSortColumn(sortBy)), ) .limit(limit) .offset(offset); const collectionsResult = await collectionsQuery; // Get total count with same conditions const totalCountQuery = this.db .selectDistinct({ id: collections.id, }) .from(collections) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where( and( eq(collectionCards.addedBy, contributorId), sql`${collections.authorId} != ${contributorId}`, eq(collections.accessType, 'OPEN'), ), ); const totalCountResult = await totalCountQuery; const totalCount = totalCountResult.length; const hasMore = offset + collectionsResult.length < totalCount; // Map to DTOs const items = collectionsResult.map((raw) => CollectionMapper.toQueryResult({ id: raw.id, uri: raw.uri, name: raw.name, description: raw.description, accessType: raw.accessType, createdAt: raw.createdAt, updatedAt: raw.updatedAt, authorId: raw.authorId, cardCount: raw.cardCount, }), ); return { items, totalCount, hasMore, }; } catch (error) { console.error('Error in getOpenCollectionsWithContributor:', error); throw error; } } async getCollectionContributors( collectionId: string, authorId: string, options: { page: number; limit: number }, ): Promise> { try { const { page, limit } = options; const offset = (page - 1) * limit; // Get unique contributors with their contribution count and last contribution time // Exclude the collection author const contributorsQuery = this.db .select({ userId: collectionCards.addedBy, contributionCount: sql`CAST(COUNT(*) AS INTEGER)`.as( 'contribution_count', ), lastContributedAt: sql`MAX(${collectionCards.addedAt})`.as( 'last_contributed_at', ), }) .from(collectionCards) .where( and( eq(collectionCards.collectionId, collectionId), sql`${collectionCards.addedBy} != ${authorId}`, // Exclude author ), ) .groupBy(collectionCards.addedBy) .orderBy(sql`MAX(${collectionCards.addedAt}) DESC`) // Most recent contribution first .limit(limit) .offset(offset); const contributors = await contributorsQuery; // Get total count of distinct contributors (excluding author) const countQuery = this.db .select({ count: sql`COUNT(DISTINCT ${collectionCards.addedBy})`, }) .from(collectionCards) .where( and( eq(collectionCards.collectionId, collectionId), sql`${collectionCards.addedBy} != ${authorId}`, ), ); const countResult = await countQuery; const totalCount = countResult[0]?.count || 0; return { items: contributors.map((c) => ({ userId: c.userId, contributionCount: c.contributionCount, lastContributedAt: c.lastContributedAt, })), totalCount, hasMore: page * limit < totalCount, }; } catch (error) { console.error('Error in getCollectionContributors:', error); throw error; } } async getCollectionCountForUrl(url: string): Promise { try { // Count distinct collections that contain cards with this URL const result = await this.db .select({ count: sql`COUNT(DISTINCT ${collectionCards.collectionId})`, }) .from(collectionCards) .innerJoin(cards, eq(collectionCards.cardId, cards.id)) .where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, url))); return Number(result[0]?.count || 0); } catch (error) { console.error('Error in getCollectionCountForUrl:', error); throw error; } } async getProfileCollectionCount(authorId: string): Promise { try { // Count collections created by this user const result = await this.db .select({ count: count(collections.id), }) .from(collections) .where(eq(collections.authorId, authorId)); return Number(result[0]?.count || 0); } catch (error) { console.error('Error in getProfileCollectionCount:', error); throw error; } } private getSortColumn(sortBy: CollectionSortField) { switch (sortBy) { case CollectionSortField.NAME: return collections.name; case CollectionSortField.CREATED_AT: return collections.createdAt; case CollectionSortField.UPDATED_AT: return collections.updatedAt; case CollectionSortField.CARD_COUNT: return collections.cardCount; default: return collections.name; } } }