A social knowledge tool for researchers built on ATProto
45
fork

Configure Feed

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

refactor: Implement query service classes for card repository

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+550 -398
+25 -398
src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts
··· 1 - import { eq, desc, asc, count, inArray, and } from 'drizzle-orm'; 2 1 import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 2 import { 4 3 ICardQueryRepository, ··· 7 6 UrlCardQueryResultDTO, 8 7 CollectionCardQueryResultDTO, 9 8 UrlCardViewDTO, 10 - CardSortField, 11 - SortOrder, 9 + LibraryForUrlDTO, 12 10 } from '../../domain/ICardQueryRepository'; 13 - import { cards } from './schema/card.sql'; 14 - import { collections, collectionCards } from './schema/collection.sql'; 15 - import { libraryMemberships } from './schema/libraryMembership.sql'; 16 - import { CardMapper, RawUrlCardData } from './mappers/CardMapper'; 17 - import { CardTypeEnum } from '../../domain/value-objects/CardType'; 11 + import { UrlCardQueryService } from './query-services/UrlCardQueryService'; 12 + import { CollectionCardQueryService } from './query-services/CollectionCardQueryService'; 13 + import { LibraryQueryService } from './query-services/LibraryQueryService'; 18 14 19 15 export class DrizzleCardQueryRepository implements ICardQueryRepository { 20 - constructor(private db: PostgresJsDatabase) {} 16 + private urlCardQueryService: UrlCardQueryService; 17 + private collectionCardQueryService: CollectionCardQueryService; 18 + private libraryQueryService: LibraryQueryService; 19 + 20 + constructor(private db: PostgresJsDatabase) { 21 + this.urlCardQueryService = new UrlCardQueryService(db); 22 + this.collectionCardQueryService = new CollectionCardQueryService(db); 23 + this.libraryQueryService = new LibraryQueryService(db); 24 + } 21 25 22 26 async getUrlCardsOfUser( 23 27 userId: string, 24 28 options: CardQueryOptions, 25 29 ): Promise<PaginatedQueryResult<UrlCardQueryResultDTO>> { 26 - try { 27 - const { page, limit, sortBy, sortOrder } = options; 28 - const offset = (page - 1) * limit; 29 - 30 - // Build the sort order 31 - const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 32 - 33 - // First, get the URL cards for the user from library memberships 34 - const urlCardsQuery = this.db 35 - .select({ 36 - id: cards.id, 37 - url: cards.url, 38 - contentData: cards.contentData, 39 - libraryCount: cards.libraryCount, 40 - createdAt: cards.createdAt, 41 - updatedAt: cards.updatedAt, 42 - }) 43 - .from(cards) 44 - .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 45 - .where( 46 - and( 47 - eq(libraryMemberships.userId, userId), 48 - eq(cards.type, CardTypeEnum.URL), 49 - ), 50 - ) 51 - .orderBy(orderDirection(this.getSortColumn(sortBy))) 52 - .limit(limit) 53 - .offset(offset); 54 - 55 - const urlCardsResult = await urlCardsQuery; 56 - 57 - if (urlCardsResult.length === 0) { 58 - return { 59 - items: [], 60 - totalCount: 0, 61 - hasMore: false, 62 - }; 63 - } 64 - 65 - const cardIds = urlCardsResult.map((card) => card.id); 66 - 67 - // Get collections for these cards 68 - const collectionsQuery = this.db 69 - .select({ 70 - cardId: collectionCards.cardId, 71 - collectionId: collections.id, 72 - collectionName: collections.name, 73 - authorId: collections.authorId, 74 - }) 75 - .from(collectionCards) 76 - .innerJoin( 77 - collections, 78 - eq(collectionCards.collectionId, collections.id), 79 - ) 80 - .where(inArray(collectionCards.cardId, cardIds)); 81 - 82 - const collectionsResult = await collectionsQuery; 83 - 84 - // Get note cards for these URL cards (same user, parentCardId matches, type = NOTE) 85 - const notesQuery = this.db 86 - .select({ 87 - id: cards.id, 88 - parentCardId: cards.parentCardId, 89 - contentData: cards.contentData, 90 - }) 91 - .from(cards) 92 - .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 93 - .where( 94 - and( 95 - eq(libraryMemberships.userId, userId), 96 - eq(cards.type, CardTypeEnum.NOTE), 97 - inArray(cards.parentCardId, cardIds), 98 - ), 99 - ); 100 - 101 - const notesResult = await notesQuery; 102 - 103 - // Get total count 104 - const totalCountResult = await this.db 105 - .select({ count: count() }) 106 - .from(cards) 107 - .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 108 - .where( 109 - and( 110 - eq(libraryMemberships.userId, userId), 111 - eq(cards.type, CardTypeEnum.URL), 112 - ), 113 - ); 114 - 115 - const totalCount = totalCountResult[0]?.count || 0; 116 - const hasMore = offset + urlCardsResult.length < totalCount; 117 - 118 - // Combine the data 119 - const rawCardData: RawUrlCardData[] = urlCardsResult.map((card) => { 120 - // Find collections for this card 121 - const cardCollections = collectionsResult 122 - .filter((c) => c.cardId === card.id) 123 - .map((c) => ({ 124 - id: c.collectionId, 125 - name: c.collectionName, 126 - authorId: c.authorId, 127 - })); 128 - 129 - // Find note for this card 130 - const note = notesResult.find((n) => n.parentCardId === card.id); 131 - 132 - return { 133 - id: card.id, 134 - url: card.url || '', 135 - contentData: card.contentData, 136 - libraryCount: card.libraryCount, 137 - createdAt: card.createdAt, 138 - updatedAt: card.updatedAt, 139 - collections: cardCollections, 140 - note: note 141 - ? { 142 - id: note.id, 143 - contentData: note.contentData, 144 - } 145 - : undefined, 146 - }; 147 - }); 148 - 149 - // Map to DTOs 150 - const items = rawCardData.map((raw) => 151 - CardMapper.toUrlCardQueryResult(raw), 152 - ); 153 - 154 - return { 155 - items, 156 - totalCount, 157 - hasMore, 158 - }; 159 - } catch (error) { 160 - console.error('Error in getUrlCardsOfUser:', error); 161 - throw error; 162 - } 30 + return this.urlCardQueryService.getUrlCardsOfUser(userId, options); 163 31 } 164 32 165 33 async getCardsInCollection( 166 34 collectionId: string, 167 35 options: CardQueryOptions, 168 36 ): Promise<PaginatedQueryResult<CollectionCardQueryResultDTO>> { 169 - try { 170 - const { page, limit, sortBy, sortOrder } = options; 171 - const offset = (page - 1) * limit; 172 - 173 - // Build the sort order 174 - const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 175 - 176 - // First, get the collection to know its author 177 - const collectionQuery = this.db 178 - .select({ 179 - authorId: collections.authorId, 180 - }) 181 - .from(collections) 182 - .where(eq(collections.id, collectionId)); 183 - 184 - const collectionResult = await collectionQuery; 185 - 186 - if (collectionResult.length === 0) { 187 - return { 188 - items: [], 189 - totalCount: 0, 190 - hasMore: false, 191 - }; 192 - } 193 - 194 - const collectionAuthorId = collectionResult[0]!.authorId; 195 - 196 - // Get URL cards in the collection 197 - const cardsQuery = this.db 198 - .select({ 199 - id: cards.id, 200 - url: cards.url, 201 - contentData: cards.contentData, 202 - libraryCount: cards.libraryCount, 203 - createdAt: cards.createdAt, 204 - updatedAt: cards.updatedAt, 205 - }) 206 - .from(cards) 207 - .innerJoin(collectionCards, eq(cards.id, collectionCards.cardId)) 208 - .where( 209 - and( 210 - eq(collectionCards.collectionId, collectionId), 211 - eq(cards.type, CardTypeEnum.URL), 212 - ), 213 - ) 214 - .orderBy(orderDirection(this.getSortColumn(sortBy))) 215 - .limit(limit) 216 - .offset(offset); 217 - 218 - const cardsResult = await cardsQuery; 219 - 220 - if (cardsResult.length === 0) { 221 - return { 222 - items: [], 223 - totalCount: 0, 224 - hasMore: false, 225 - }; 226 - } 227 - 228 - const cardIds = cardsResult.map((card) => card.id); 229 - 230 - // Get note cards for these URL cards, but only by the collection author 231 - const notesQuery = this.db 232 - .select({ 233 - id: cards.id, 234 - parentCardId: cards.parentCardId, 235 - contentData: cards.contentData, 236 - }) 237 - .from(cards) 238 - .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 239 - .where( 240 - and( 241 - eq(cards.type, CardTypeEnum.NOTE), 242 - inArray(cards.parentCardId, cardIds), 243 - eq(libraryMemberships.userId, collectionAuthorId), 244 - ), 245 - ); 246 - 247 - const notesResult = await notesQuery; 248 - 249 - // Get total count 250 - const totalCountResult = await this.db 251 - .select({ count: count() }) 252 - .from(cards) 253 - .innerJoin(collectionCards, eq(cards.id, collectionCards.cardId)) 254 - .where( 255 - and( 256 - eq(collectionCards.collectionId, collectionId), 257 - eq(cards.type, CardTypeEnum.URL), 258 - ), 259 - ); 260 - 261 - const totalCount = totalCountResult[0]?.count || 0; 262 - const hasMore = offset + cardsResult.length < totalCount; 263 - 264 - // Combine the data 265 - const rawCardData = cardsResult.map((card) => { 266 - // Find note for this card 267 - const note = notesResult.find((n) => n.parentCardId === card.id); 268 - 269 - return { 270 - id: card.id, 271 - url: card.url || '', 272 - contentData: card.contentData, 273 - libraryCount: card.libraryCount, 274 - createdAt: card.createdAt, 275 - updatedAt: card.updatedAt, 276 - note: note 277 - ? { 278 - id: note.id, 279 - contentData: note.contentData, 280 - } 281 - : undefined, 282 - }; 283 - }); 284 - 285 - // Map to DTOs 286 - const items = rawCardData.map((raw) => 287 - CardMapper.toCollectionCardQueryResult(raw), 288 - ); 289 - 290 - return { 291 - items, 292 - totalCount, 293 - hasMore, 294 - }; 295 - } catch (error) { 296 - console.error('Error in getCardsInCollection:', error); 297 - throw error; 298 - } 37 + return this.collectionCardQueryService.getCardsInCollection( 38 + collectionId, 39 + options, 40 + ); 299 41 } 300 42 301 43 async getUrlCardView(cardId: string): Promise<UrlCardViewDTO | null> { 302 - try { 303 - // Get the URL card 304 - const cardQuery = this.db 305 - .select({ 306 - id: cards.id, 307 - type: cards.type, 308 - url: cards.url, 309 - contentData: cards.contentData, 310 - libraryCount: cards.libraryCount, 311 - createdAt: cards.createdAt, 312 - updatedAt: cards.updatedAt, 313 - }) 314 - .from(cards) 315 - .where(and(eq(cards.id, cardId), eq(cards.type, CardTypeEnum.URL))); 316 - 317 - const cardResult = await cardQuery; 318 - 319 - if (cardResult.length === 0) { 320 - return null; 321 - } 322 - 323 - const card = cardResult[0]!; 324 - 325 - // Get users who have this card in their libraries 326 - const libraryQuery = this.db 327 - .select({ 328 - userId: libraryMemberships.userId, 329 - }) 330 - .from(libraryMemberships) 331 - .where(eq(libraryMemberships.cardId, cardId)); 332 - 333 - const libraryResult = await libraryQuery; 334 - 335 - // Get collections that contain this card 336 - const collectionsQuery = this.db 337 - .select({ 338 - collectionId: collections.id, 339 - collectionName: collections.name, 340 - authorId: collections.authorId, 341 - }) 342 - .from(collectionCards) 343 - .innerJoin( 344 - collections, 345 - eq(collectionCards.collectionId, collections.id), 346 - ) 347 - .where(eq(collectionCards.cardId, cardId)); 348 - 349 - const collectionsResult = await collectionsQuery; 350 - 351 - // Get note card for this URL card (parentCardId matches, type = NOTE) 352 - const noteQuery = this.db 353 - .select({ 354 - id: cards.id, 355 - parentCardId: cards.parentCardId, 356 - contentData: cards.contentData, 357 - }) 358 - .from(cards) 359 - .where( 360 - and( 361 - eq(cards.type, CardTypeEnum.NOTE), 362 - eq(cards.parentCardId, cardId), 363 - ), 364 - ); 365 - 366 - const noteResult = await noteQuery; 367 - const note = noteResult.length > 0 ? noteResult[0] : undefined; 368 - 369 - // Map to DTO 370 - const urlCardView = CardMapper.toUrlCardViewDTO({ 371 - id: card.id, 372 - type: card.type, 373 - url: card.url || '', 374 - contentData: card.contentData, 375 - libraryCount: card.libraryCount, 376 - createdAt: card.createdAt, 377 - updatedAt: card.updatedAt, 378 - inLibraries: libraryResult.map((lib) => ({ userId: lib.userId })), 379 - inCollections: collectionsResult.map((coll) => ({ 380 - id: coll.collectionId, 381 - name: coll.collectionName, 382 - authorId: coll.authorId, 383 - })), 384 - note: note 385 - ? { 386 - id: note.id, 387 - contentData: note.contentData, 388 - } 389 - : undefined, 390 - }); 391 - 392 - return urlCardView; 393 - } catch (error) { 394 - console.error('Error in getUrlCardView:', error); 395 - throw error; 396 - } 44 + return this.urlCardQueryService.getUrlCardView(cardId); 397 45 } 398 46 399 47 async getLibrariesForCard(cardId: string): Promise<string[]> { 400 - try { 401 - // Get all users who have this card in their library 402 - const libraryQuery = this.db 403 - .select({ 404 - userId: libraryMemberships.userId, 405 - }) 406 - .from(libraryMemberships) 407 - .where(eq(libraryMemberships.cardId, cardId)); 408 - 409 - const libraryResult = await libraryQuery; 410 - 411 - return libraryResult.map((lib) => lib.userId); 412 - } catch (error) { 413 - console.error('Error in getLibrariesForCard:', error); 414 - throw error; 415 - } 48 + return this.libraryQueryService.getLibrariesForCard(cardId); 416 49 } 417 50 418 - private getSortColumn(sortBy: CardSortField) { 419 - switch (sortBy) { 420 - case CardSortField.CREATED_AT: 421 - return cards.createdAt; 422 - case CardSortField.UPDATED_AT: 423 - return cards.updatedAt; 424 - case CardSortField.LIBRARY_COUNT: 425 - return cards.libraryCount; 426 - default: 427 - return cards.updatedAt; 428 - } 51 + async getLibrariesForUrl( 52 + url: string, 53 + options: CardQueryOptions, 54 + ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 55 + return this.urlCardQueryService.getLibrariesForUrl(url, options); 429 56 } 430 57 }
+167
src/modules/cards/infrastructure/repositories/query-services/CollectionCardQueryService.ts
··· 1 + import { eq, desc, asc, count, inArray, and } from 'drizzle-orm'; 2 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 + import { 4 + CardQueryOptions, 5 + PaginatedQueryResult, 6 + CollectionCardQueryResultDTO, 7 + CardSortField, 8 + SortOrder, 9 + } from '../../../domain/ICardQueryRepository'; 10 + import { cards } from '../schema/card.sql'; 11 + import { collections, collectionCards } from '../schema/collection.sql'; 12 + import { libraryMemberships } from '../schema/libraryMembership.sql'; 13 + import { CardMapper } from '../mappers/CardMapper'; 14 + import { CardTypeEnum } from '../../../domain/value-objects/CardType'; 15 + 16 + export class CollectionCardQueryService { 17 + constructor(private db: PostgresJsDatabase) {} 18 + 19 + async getCardsInCollection( 20 + collectionId: string, 21 + options: CardQueryOptions, 22 + ): Promise<PaginatedQueryResult<CollectionCardQueryResultDTO>> { 23 + try { 24 + const { page, limit, sortBy, sortOrder } = options; 25 + const offset = (page - 1) * limit; 26 + 27 + // Build the sort order 28 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 29 + 30 + // First, get the collection to know its author 31 + const collectionQuery = this.db 32 + .select({ 33 + authorId: collections.authorId, 34 + }) 35 + .from(collections) 36 + .where(eq(collections.id, collectionId)); 37 + 38 + const collectionResult = await collectionQuery; 39 + 40 + if (collectionResult.length === 0) { 41 + return { 42 + items: [], 43 + totalCount: 0, 44 + hasMore: false, 45 + }; 46 + } 47 + 48 + const collectionAuthorId = collectionResult[0]!.authorId; 49 + 50 + // Get URL cards in the collection 51 + const cardsQuery = this.db 52 + .select({ 53 + id: cards.id, 54 + url: cards.url, 55 + contentData: cards.contentData, 56 + libraryCount: cards.libraryCount, 57 + createdAt: cards.createdAt, 58 + updatedAt: cards.updatedAt, 59 + }) 60 + .from(cards) 61 + .innerJoin(collectionCards, eq(cards.id, collectionCards.cardId)) 62 + .where( 63 + and( 64 + eq(collectionCards.collectionId, collectionId), 65 + eq(cards.type, CardTypeEnum.URL), 66 + ), 67 + ) 68 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 69 + .limit(limit) 70 + .offset(offset); 71 + 72 + const cardsResult = await cardsQuery; 73 + 74 + if (cardsResult.length === 0) { 75 + return { 76 + items: [], 77 + totalCount: 0, 78 + hasMore: false, 79 + }; 80 + } 81 + 82 + const cardIds = cardsResult.map((card) => card.id); 83 + 84 + // Get note cards for these URL cards, but only by the collection author 85 + const notesQuery = this.db 86 + .select({ 87 + id: cards.id, 88 + parentCardId: cards.parentCardId, 89 + contentData: cards.contentData, 90 + }) 91 + .from(cards) 92 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 93 + .where( 94 + and( 95 + eq(cards.type, CardTypeEnum.NOTE), 96 + inArray(cards.parentCardId, cardIds), 97 + eq(libraryMemberships.userId, collectionAuthorId), 98 + ), 99 + ); 100 + 101 + const notesResult = await notesQuery; 102 + 103 + // Get total count 104 + const totalCountResult = await this.db 105 + .select({ count: count() }) 106 + .from(cards) 107 + .innerJoin(collectionCards, eq(cards.id, collectionCards.cardId)) 108 + .where( 109 + and( 110 + eq(collectionCards.collectionId, collectionId), 111 + eq(cards.type, CardTypeEnum.URL), 112 + ), 113 + ); 114 + 115 + const totalCount = totalCountResult[0]?.count || 0; 116 + const hasMore = offset + cardsResult.length < totalCount; 117 + 118 + // Combine the data 119 + const rawCardData = cardsResult.map((card) => { 120 + // Find note for this card 121 + const note = notesResult.find((n) => n.parentCardId === card.id); 122 + 123 + return { 124 + id: card.id, 125 + url: card.url || '', 126 + contentData: card.contentData, 127 + libraryCount: card.libraryCount, 128 + createdAt: card.createdAt, 129 + updatedAt: card.updatedAt, 130 + note: note 131 + ? { 132 + id: note.id, 133 + contentData: note.contentData, 134 + } 135 + : undefined, 136 + }; 137 + }); 138 + 139 + // Map to DTOs 140 + const items = rawCardData.map((raw) => 141 + CardMapper.toCollectionCardQueryResult(raw), 142 + ); 143 + 144 + return { 145 + items, 146 + totalCount, 147 + hasMore, 148 + }; 149 + } catch (error) { 150 + console.error('Error in getCardsInCollection:', error); 151 + throw error; 152 + } 153 + } 154 + 155 + private getSortColumn(sortBy: CardSortField) { 156 + switch (sortBy) { 157 + case CardSortField.CREATED_AT: 158 + return cards.createdAt; 159 + case CardSortField.UPDATED_AT: 160 + return cards.updatedAt; 161 + case CardSortField.LIBRARY_COUNT: 162 + return cards.libraryCount; 163 + default: 164 + return cards.updatedAt; 165 + } 166 + } 167 + }
+26
src/modules/cards/infrastructure/repositories/query-services/LibraryQueryService.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 + import { libraryMemberships } from '../schema/libraryMembership.sql'; 4 + 5 + export class LibraryQueryService { 6 + constructor(private db: PostgresJsDatabase) {} 7 + 8 + async getLibrariesForCard(cardId: string): Promise<string[]> { 9 + try { 10 + // Get all users who have this card in their library 11 + const libraryQuery = this.db 12 + .select({ 13 + userId: libraryMemberships.userId, 14 + }) 15 + .from(libraryMemberships) 16 + .where(eq(libraryMemberships.cardId, cardId)); 17 + 18 + const libraryResult = await libraryQuery; 19 + 20 + return libraryResult.map((lib) => lib.userId); 21 + } catch (error) { 22 + console.error('Error in getLibrariesForCard:', error); 23 + throw error; 24 + } 25 + } 26 + }
+332
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 1 + import { eq, desc, asc, count, inArray, and } from 'drizzle-orm'; 2 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 + import { 4 + CardQueryOptions, 5 + PaginatedQueryResult, 6 + UrlCardQueryResultDTO, 7 + UrlCardViewDTO, 8 + LibraryForUrlDTO, 9 + CardSortField, 10 + SortOrder, 11 + } from '../../../domain/ICardQueryRepository'; 12 + import { cards } from '../schema/card.sql'; 13 + import { collections, collectionCards } from '../schema/collection.sql'; 14 + import { libraryMemberships } from '../schema/libraryMembership.sql'; 15 + import { CardMapper, RawUrlCardData } from '../mappers/CardMapper'; 16 + import { CardTypeEnum } from '../../../domain/value-objects/CardType'; 17 + 18 + export class UrlCardQueryService { 19 + constructor(private db: PostgresJsDatabase) {} 20 + 21 + async getUrlCardsOfUser( 22 + userId: string, 23 + options: CardQueryOptions, 24 + ): Promise<PaginatedQueryResult<UrlCardQueryResultDTO>> { 25 + try { 26 + const { page, limit, sortBy, sortOrder } = options; 27 + const offset = (page - 1) * limit; 28 + 29 + // Build the sort order 30 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 31 + 32 + // First, get the URL cards for the user from library memberships 33 + const urlCardsQuery = this.db 34 + .select({ 35 + id: cards.id, 36 + url: cards.url, 37 + contentData: cards.contentData, 38 + libraryCount: cards.libraryCount, 39 + createdAt: cards.createdAt, 40 + updatedAt: cards.updatedAt, 41 + }) 42 + .from(cards) 43 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 44 + .where( 45 + and( 46 + eq(libraryMemberships.userId, userId), 47 + eq(cards.type, CardTypeEnum.URL), 48 + ), 49 + ) 50 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 51 + .limit(limit) 52 + .offset(offset); 53 + 54 + const urlCardsResult = await urlCardsQuery; 55 + 56 + if (urlCardsResult.length === 0) { 57 + return { 58 + items: [], 59 + totalCount: 0, 60 + hasMore: false, 61 + }; 62 + } 63 + 64 + const cardIds = urlCardsResult.map((card) => card.id); 65 + 66 + // Get collections for these cards 67 + const collectionsQuery = this.db 68 + .select({ 69 + cardId: collectionCards.cardId, 70 + collectionId: collections.id, 71 + collectionName: collections.name, 72 + authorId: collections.authorId, 73 + }) 74 + .from(collectionCards) 75 + .innerJoin( 76 + collections, 77 + eq(collectionCards.collectionId, collections.id), 78 + ) 79 + .where(inArray(collectionCards.cardId, cardIds)); 80 + 81 + const collectionsResult = await collectionsQuery; 82 + 83 + // Get note cards for these URL cards (same user, parentCardId matches, type = NOTE) 84 + const notesQuery = this.db 85 + .select({ 86 + id: cards.id, 87 + parentCardId: cards.parentCardId, 88 + contentData: cards.contentData, 89 + }) 90 + .from(cards) 91 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 92 + .where( 93 + and( 94 + eq(libraryMemberships.userId, userId), 95 + eq(cards.type, CardTypeEnum.NOTE), 96 + inArray(cards.parentCardId, cardIds), 97 + ), 98 + ); 99 + 100 + const notesResult = await notesQuery; 101 + 102 + // Get total count 103 + const totalCountResult = await this.db 104 + .select({ count: count() }) 105 + .from(cards) 106 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 107 + .where( 108 + and( 109 + eq(libraryMemberships.userId, userId), 110 + eq(cards.type, CardTypeEnum.URL), 111 + ), 112 + ); 113 + 114 + const totalCount = totalCountResult[0]?.count || 0; 115 + const hasMore = offset + urlCardsResult.length < totalCount; 116 + 117 + // Combine the data 118 + const rawCardData: RawUrlCardData[] = urlCardsResult.map((card) => { 119 + // Find collections for this card 120 + const cardCollections = collectionsResult 121 + .filter((c) => c.cardId === card.id) 122 + .map((c) => ({ 123 + id: c.collectionId, 124 + name: c.collectionName, 125 + authorId: c.authorId, 126 + })); 127 + 128 + // Find note for this card 129 + const note = notesResult.find((n) => n.parentCardId === card.id); 130 + 131 + return { 132 + id: card.id, 133 + url: card.url || '', 134 + contentData: card.contentData, 135 + libraryCount: card.libraryCount, 136 + createdAt: card.createdAt, 137 + updatedAt: card.updatedAt, 138 + collections: cardCollections, 139 + note: note 140 + ? { 141 + id: note.id, 142 + contentData: note.contentData, 143 + } 144 + : undefined, 145 + }; 146 + }); 147 + 148 + // Map to DTOs 149 + const items = rawCardData.map((raw) => 150 + CardMapper.toUrlCardQueryResult(raw), 151 + ); 152 + 153 + return { 154 + items, 155 + totalCount, 156 + hasMore, 157 + }; 158 + } catch (error) { 159 + console.error('Error in getUrlCardsOfUser:', error); 160 + throw error; 161 + } 162 + } 163 + 164 + async getUrlCardView(cardId: string): Promise<UrlCardViewDTO | null> { 165 + try { 166 + // Get the URL card 167 + const cardQuery = this.db 168 + .select({ 169 + id: cards.id, 170 + type: cards.type, 171 + url: cards.url, 172 + contentData: cards.contentData, 173 + libraryCount: cards.libraryCount, 174 + createdAt: cards.createdAt, 175 + updatedAt: cards.updatedAt, 176 + }) 177 + .from(cards) 178 + .where(and(eq(cards.id, cardId), eq(cards.type, CardTypeEnum.URL))); 179 + 180 + const cardResult = await cardQuery; 181 + 182 + if (cardResult.length === 0) { 183 + return null; 184 + } 185 + 186 + const card = cardResult[0]!; 187 + 188 + // Get users who have this card in their libraries 189 + const libraryQuery = this.db 190 + .select({ 191 + userId: libraryMemberships.userId, 192 + }) 193 + .from(libraryMemberships) 194 + .where(eq(libraryMemberships.cardId, cardId)); 195 + 196 + const libraryResult = await libraryQuery; 197 + 198 + // Get collections that contain this card 199 + const collectionsQuery = this.db 200 + .select({ 201 + collectionId: collections.id, 202 + collectionName: collections.name, 203 + authorId: collections.authorId, 204 + }) 205 + .from(collectionCards) 206 + .innerJoin( 207 + collections, 208 + eq(collectionCards.collectionId, collections.id), 209 + ) 210 + .where(eq(collectionCards.cardId, cardId)); 211 + 212 + const collectionsResult = await collectionsQuery; 213 + 214 + // Get note card for this URL card (parentCardId matches, type = NOTE) 215 + const noteQuery = this.db 216 + .select({ 217 + id: cards.id, 218 + parentCardId: cards.parentCardId, 219 + contentData: cards.contentData, 220 + }) 221 + .from(cards) 222 + .where( 223 + and( 224 + eq(cards.type, CardTypeEnum.NOTE), 225 + eq(cards.parentCardId, cardId), 226 + ), 227 + ); 228 + 229 + const noteResult = await noteQuery; 230 + const note = noteResult.length > 0 ? noteResult[0] : undefined; 231 + 232 + // Map to DTO 233 + const urlCardView = CardMapper.toUrlCardViewDTO({ 234 + id: card.id, 235 + type: card.type, 236 + url: card.url || '', 237 + contentData: card.contentData, 238 + libraryCount: card.libraryCount, 239 + createdAt: card.createdAt, 240 + updatedAt: card.updatedAt, 241 + inLibraries: libraryResult.map((lib) => ({ userId: lib.userId })), 242 + inCollections: collectionsResult.map((coll) => ({ 243 + id: coll.collectionId, 244 + name: coll.collectionName, 245 + authorId: coll.authorId, 246 + })), 247 + note: note 248 + ? { 249 + id: note.id, 250 + contentData: note.contentData, 251 + } 252 + : undefined, 253 + }); 254 + 255 + return urlCardView; 256 + } catch (error) { 257 + console.error('Error in getUrlCardView:', error); 258 + throw error; 259 + } 260 + } 261 + 262 + async getLibrariesForUrl( 263 + url: string, 264 + options: CardQueryOptions, 265 + ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 266 + try { 267 + const { page, limit } = options; 268 + const offset = (page - 1) * limit; 269 + 270 + // Get all URL cards with this URL and their library memberships 271 + const librariesQuery = this.db 272 + .select({ 273 + userId: libraryMemberships.userId, 274 + cardId: libraryMemberships.cardId, 275 + }) 276 + .from(libraryMemberships) 277 + .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 278 + .where( 279 + and( 280 + eq(cards.url, url), 281 + eq(cards.type, CardTypeEnum.URL), 282 + ), 283 + ) 284 + .limit(limit) 285 + .offset(offset); 286 + 287 + const librariesResult = await librariesQuery; 288 + 289 + // Get total count 290 + const totalCountResult = await this.db 291 + .select({ count: count() }) 292 + .from(libraryMemberships) 293 + .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 294 + .where( 295 + and( 296 + eq(cards.url, url), 297 + eq(cards.type, CardTypeEnum.URL), 298 + ), 299 + ); 300 + 301 + const totalCount = totalCountResult[0]?.count || 0; 302 + const hasMore = offset + librariesResult.length < totalCount; 303 + 304 + const items = librariesResult.map((lib) => ({ 305 + userId: lib.userId, 306 + cardId: lib.cardId, 307 + })); 308 + 309 + return { 310 + items, 311 + totalCount, 312 + hasMore, 313 + }; 314 + } catch (error) { 315 + console.error('Error in getLibrariesForUrl:', error); 316 + throw error; 317 + } 318 + } 319 + 320 + private getSortColumn(sortBy: CardSortField) { 321 + switch (sortBy) { 322 + case CardSortField.CREATED_AT: 323 + return cards.createdAt; 324 + case CardSortField.UPDATED_AT: 325 + return cards.updatedAt; 326 + case CardSortField.LIBRARY_COUNT: 327 + return cards.libraryCount; 328 + default: 329 + return cards.updatedAt; 330 + } 331 + } 332 + }