This repository has no description
0

Configure Feed

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

wip: unifying response types

+485 -207
+30 -5
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
··· 7 7 } from '../../../domain/ICollectionQueryRepository'; 8 8 import { URL } from '../../../domain/value-objects/URL'; 9 9 import { IProfileService } from '../../../domain/services/IProfileService'; 10 + import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 11 + import { CollectionId } from '../../../domain/value-objects/CollectionId'; 10 12 11 13 export interface GetCollectionsForUrlQuery { 12 14 url: string; ··· 27 29 name: string; 28 30 handle: string; 29 31 avatarUrl?: string; 32 + description?: string; 30 33 }; 34 + cardCount: number; 35 + createdAt: string; 36 + updatedAt: string; 31 37 } 32 38 33 39 export interface GetCollectionsForUrlResult { ··· 59 65 constructor( 60 66 private collectionQueryRepo: ICollectionQueryRepository, 61 67 private profileService: IProfileService, 68 + private collectionRepo: ICollectionRepository, 62 69 ) {} 63 70 64 71 async execute( ··· 104 111 // Create a map of profiles 105 112 const profileMap = new Map< 106 113 string, 107 - { id: string; name: string; handle: string; avatarUrl?: string } 114 + { id: string; name: string; handle: string; avatarUrl?: string; description?: string } 108 115 >(); 109 116 110 117 for (let i = 0; i < uniqueAuthorIds.length; i++) { ··· 126 133 name: profile.name, 127 134 handle: profile.handle, 128 135 avatarUrl: profile.avatarUrl, 136 + description: profile.bio, 129 137 }); 130 138 } 131 139 132 - // Map items with enriched author data 133 - const enrichedCollections: CollectionForUrlDTO[] = result.items.map( 134 - (item) => { 140 + // Map items with enriched author data and full collection data 141 + const enrichedCollections: CollectionForUrlDTO[] = await Promise.all( 142 + result.items.map(async (item) => { 135 143 const author = profileMap.get(item.authorId); 136 144 if (!author) { 137 145 throw new Error(`Profile not found for author ${item.authorId}`); 138 146 } 147 + 148 + // Fetch full collection to get cardCount, dates 149 + const collectionIdResult = CollectionId.createFromString(item.id); 150 + if (collectionIdResult.isErr()) { 151 + throw new Error(`Invalid collection ID: ${item.id}`); 152 + } 153 + const collectionResult = await this.collectionRepo.findById( 154 + collectionIdResult.value, 155 + ); 156 + if (collectionResult.isErr()) { 157 + throw new Error(`Collection not found: ${item.id}`); 158 + } 159 + const collection = collectionResult.value; 160 + 139 161 return { 140 162 id: item.id, 141 163 uri: item.uri, 142 164 name: item.name, 143 165 description: item.description, 144 166 author, 167 + cardCount: collection.cardCount, 168 + createdAt: collection.createdAt.toISOString(), 169 + updatedAt: collection.updatedAt.toISOString(), 145 170 }; 146 - }, 171 + }), 147 172 ); 148 173 149 174 return ok({
+4 -2
src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts
··· 27 27 updatedAt: Date; 28 28 createdAt: Date; 29 29 cardCount: number; 30 - createdBy: { 30 + author: { 31 31 id: string; 32 32 name: string; 33 33 handle: string; 34 34 avatarUrl?: string; 35 + description?: string; 35 36 }; 36 37 } 37 38 export interface GetCollectionsResult { ··· 127 128 updatedAt: item.updatedAt, 128 129 createdAt: item.createdAt, 129 130 cardCount: item.cardCount, 130 - createdBy: { 131 + author: { 131 132 id: profile.id, 132 133 name: profile.name, 133 134 handle: profile.handle, 134 135 avatarUrl: profile.avatarUrl, 136 + description: profile.bio, 135 137 }, 136 138 }; 137 139 },
+2
src/modules/cards/application/useCases/queries/GetLibrariesForCardUseCase.ts
··· 12 12 name: string; 13 13 handle: string; 14 14 avatarUrl?: string; 15 + description?: string; 15 16 } 16 17 17 18 export interface GetLibrariesForCardResult { ··· 74 75 name: profile.name, 75 76 handle: profile.handle, 76 77 avatarUrl: profile.avatarUrl, 78 + description: profile.bio, 77 79 }); 78 80 } else { 79 81 errors.push(
+110 -4
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
··· 4 4 ICardQueryRepository, 5 5 CardSortField, 6 6 SortOrder, 7 - LibraryForUrlDTO, 8 7 } from '../../../domain/ICardQueryRepository'; 9 8 import { URL } from '../../../domain/value-objects/URL'; 9 + import { IProfileService } from '../../../domain/services/IProfileService'; 10 10 11 11 export interface GetLibrariesForUrlQuery { 12 12 url: string; 13 + callingUserId?: string; 13 14 page?: number; 14 15 limit?: number; 15 16 sortBy?: CardSortField; 16 17 sortOrder?: SortOrder; 17 18 } 18 19 20 + export interface UserDTO { 21 + id: string; 22 + name: string; 23 + handle: string; 24 + avatarUrl?: string; 25 + description?: string; 26 + } 27 + 28 + export interface UrlCardDTO { 29 + id: string; 30 + type: 'URL'; 31 + url: string; 32 + cardContent: { 33 + url: string; 34 + title?: string; 35 + description?: string; 36 + author?: string; 37 + thumbnailUrl?: string; 38 + }; 39 + libraryCount: number; 40 + urlLibraryCount: number; 41 + urlInLibrary?: boolean; 42 + createdAt: string; 43 + updatedAt: string; 44 + author: UserDTO; 45 + note?: { 46 + id: string; 47 + text: string; 48 + }; 49 + } 50 + 51 + export interface LibraryForUrlDTO { 52 + user: UserDTO; 53 + card: UrlCardDTO; 54 + } 55 + 19 56 export interface GetLibrariesForUrlResult { 20 57 libraries: LibraryForUrlDTO[]; 21 58 pagination: { ··· 41 78 export class GetLibrariesForUrlUseCase 42 79 implements UseCase<GetLibrariesForUrlQuery, Result<GetLibrariesForUrlResult>> 43 80 { 44 - constructor(private cardQueryRepo: ICardQueryRepository) {} 81 + constructor( 82 + private cardQueryRepo: ICardQueryRepository, 83 + private profileService: IProfileService, 84 + ) {} 45 85 46 86 async execute( 47 87 query: GetLibrariesForUrlQuery, ··· 61 101 const sortOrder = query.sortOrder || SortOrder.DESC; 62 102 63 103 try { 64 - // Execute query to get libraries for the URL 104 + // Execute query to get libraries with full card data 65 105 const result = await this.cardQueryRepo.getLibrariesForUrl(query.url, { 66 106 page, 67 107 limit, ··· 69 109 sortOrder, 70 110 }); 71 111 112 + // Enrich with user profiles 113 + const uniqueUserIds = Array.from( 114 + new Set(result.items.map((item) => item.userId)), 115 + ); 116 + 117 + const profilePromises = uniqueUserIds.map((userId) => 118 + this.profileService.getProfile(userId, query.callingUserId), 119 + ); 120 + 121 + const profileResults = await Promise.all(profilePromises); 122 + 123 + // Create a map of profiles 124 + const profileMap = new Map<string, UserDTO>(); 125 + 126 + for (let i = 0; i < uniqueUserIds.length; i++) { 127 + const profileResult = profileResults[i]; 128 + const userId = uniqueUserIds[i]; 129 + if (!profileResult || !userId) { 130 + return err(new Error('Missing profile result or user ID')); 131 + } 132 + if (profileResult.isErr()) { 133 + return err( 134 + new Error( 135 + `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 136 + ), 137 + ); 138 + } 139 + const profile = profileResult.value; 140 + profileMap.set(userId, { 141 + id: profile.id, 142 + name: profile.name, 143 + handle: profile.handle, 144 + avatarUrl: profile.avatarUrl, 145 + description: profile.bio, 146 + }); 147 + } 148 + 149 + // Map items with enriched user data and card data 150 + const enrichedLibraries: LibraryForUrlDTO[] = result.items.map((item) => { 151 + const user = profileMap.get(item.userId); 152 + if (!user) { 153 + throw new Error(`Profile not found for user ${item.userId}`); 154 + } 155 + 156 + // Build card object 157 + // Note: userId is the card author (it's their card in their library) 158 + const card: UrlCardDTO = { 159 + id: item.card.id, 160 + type: 'URL', 161 + url: item.card.url, 162 + cardContent: item.card.cardContent, 163 + libraryCount: item.card.libraryCount, 164 + urlLibraryCount: item.card.urlLibraryCount, 165 + urlInLibrary: item.card.urlInLibrary, 166 + createdAt: item.card.createdAt.toISOString(), 167 + updatedAt: item.card.updatedAt.toISOString(), 168 + author: user, // Card author is same as library user 169 + note: item.card.note, 170 + }; 171 + 172 + return { 173 + user, 174 + card, 175 + }; 176 + }); 177 + 72 178 return ok({ 73 - libraries: result.items, 179 + libraries: enrichedLibraries, 74 180 pagination: { 75 181 currentPage: page, 76 182 totalPages: Math.ceil(result.totalCount / limit),
+3 -1
src/modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase.ts
··· 25 25 name: string; 26 26 handle: string; 27 27 avatarUrl?: string; 28 + description?: string; 28 29 }; 29 30 createdAt: Date; 30 31 updatedAt: Date; ··· 100 101 // Create a map of profiles 101 102 const profileMap = new Map< 102 103 string, 103 - { id: string; name: string; handle: string; avatarUrl?: string } 104 + { id: string; name: string; handle: string; avatarUrl?: string; description?: string } 104 105 >(); 105 106 106 107 for (let i = 0; i < uniqueAuthorIds.length; i++) { ··· 122 123 name: profile.name, 123 124 handle: profile.handle, 124 125 avatarUrl: profile.avatarUrl, 126 + description: profile.bio, 125 127 }); 126 128 } 127 129
+105 -7
src/modules/cards/application/useCases/queries/GetUrlCardViewUseCase.ts
··· 7 7 WithCollections, 8 8 } from '../../../domain/ICardQueryRepository'; 9 9 import { IProfileService } from '../../../domain/services/IProfileService'; 10 + import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 11 + import { CollectionId } from '../../../domain/value-objects/CollectionId'; 10 12 11 13 export interface GetUrlCardViewQuery { 12 14 cardId: string; ··· 14 16 } 15 17 16 18 // Enriched data for the final use case result 17 - export type UrlCardViewResult = UrlCardView & 18 - WithCollections & { 19 - libraries: { 20 - userId: string; 19 + export type UrlCardViewResult = UrlCardView & { 20 + author: { 21 + id: string; 22 + name: string; 23 + handle: string; 24 + avatarUrl?: string; 25 + description?: string; 26 + }; 27 + collections: { 28 + id: string; 29 + uri?: string; 30 + name: string; 31 + description?: string; 32 + author: { 33 + id: string; 21 34 name: string; 22 35 handle: string; 23 36 avatarUrl?: string; 24 - }[]; 25 - }; 37 + description?: string; 38 + }; 39 + cardCount: number; 40 + createdAt: string; 41 + updatedAt: string; 42 + }[]; 43 + libraries: { 44 + id: string; 45 + name: string; 46 + handle: string; 47 + avatarUrl?: string; 48 + description?: string; 49 + }[]; 50 + }; 26 51 27 52 export class ValidationError extends Error { 28 53 constructor(message: string) { ··· 44 69 constructor( 45 70 private cardQueryRepo: ICardQueryRepository, 46 71 private profileService: IProfileService, 72 + private collectionRepo: ICollectionRepository, 47 73 ) {} 48 74 49 75 async execute( ··· 66 92 return err(new CardNotFoundError('URL card not found')); 67 93 } 68 94 95 + // Fetch card author profile 96 + const cardAuthorResult = await this.profileService.getProfile( 97 + cardView.authorId, 98 + query.callingUserId, 99 + ); 100 + 101 + if (cardAuthorResult.isErr()) { 102 + return err( 103 + new Error( 104 + `Failed to fetch card author: ${cardAuthorResult.error.message}`, 105 + ), 106 + ); 107 + } 108 + 109 + const cardAuthor = cardAuthorResult.value; 110 + 69 111 // Get profiles for all users in libraries 70 112 const userIds = cardView.libraries.map((lib) => lib.userId); 71 113 const profilePromises = userIds.map((userId) => ··· 96 138 const profile = profileResult.value; 97 139 98 140 return { 99 - userId: lib.userId, 141 + id: profile.id, 100 142 name: profile.name, 101 143 handle: profile.handle, 102 144 avatarUrl: profile.avatarUrl, 145 + description: profile.bio, 103 146 }; 104 147 }); 105 148 149 + // Enrich collections with full Collection data 150 + const enrichedCollections = await Promise.all( 151 + cardView.collections.map(async (collection) => { 152 + const collectionIdResult = 153 + CollectionId.createFromString(collection.id); 154 + if (collectionIdResult.isErr()) { 155 + throw new Error(`Invalid collection ID: ${collection.id}`); 156 + } 157 + const collectionResult = await this.collectionRepo.findById( 158 + collectionIdResult.value, 159 + ); 160 + if (collectionResult.isErr()) { 161 + throw new Error(`Collection not found: ${collection.id}`); 162 + } 163 + const fullCollection = collectionResult.value; 164 + 165 + // Fetch collection author profile 166 + const collectionAuthorResult = await this.profileService.getProfile( 167 + fullCollection.curatorId.value, 168 + query.callingUserId, 169 + ); 170 + if (collectionAuthorResult.isErr()) { 171 + throw new Error( 172 + `Failed to fetch collection author: ${collectionAuthorResult.error.message}`, 173 + ); 174 + } 175 + const collectionAuthor = collectionAuthorResult.value; 176 + 177 + return { 178 + id: collection.id, 179 + uri: fullCollection.uri, 180 + name: collection.name, 181 + description: fullCollection.description?.value, 182 + author: { 183 + id: collectionAuthor.id, 184 + name: collectionAuthor.name, 185 + handle: collectionAuthor.handle, 186 + avatarUrl: collectionAuthor.avatarUrl, 187 + description: collectionAuthor.bio, 188 + }, 189 + cardCount: fullCollection.cardCount, 190 + createdAt: fullCollection.createdAt.toISOString(), 191 + updatedAt: fullCollection.updatedAt.toISOString(), 192 + }; 193 + }), 194 + ); 195 + 106 196 const result: UrlCardViewResult = { 107 197 ...cardView, 198 + author: { 199 + id: cardAuthor.id, 200 + name: cardAuthor.name, 201 + handle: cardAuthor.handle, 202 + avatarUrl: cardAuthor.avatarUrl, 203 + description: cardAuthor.bio, 204 + }, 205 + collections: enrichedCollections, 108 206 libraries: enrichedLibraries, 109 207 }; 110 208
+66 -6
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts
··· 5 5 import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 6 6 import { ICardRepository } from '../../../domain/ICardRepository'; 7 7 import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; 8 + import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 9 + import { IProfileService } from '../../../domain/services/IProfileService'; 8 10 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 9 11 import { URL } from '../../../domain/value-objects/URL'; 12 + import { CollectionId } from '../../../domain/value-objects/CollectionId'; 10 13 11 14 export interface GetUrlStatusForMyLibraryQuery { 12 15 url: string; ··· 18 21 uri?: string; 19 22 name: string; 20 23 description?: string; 24 + author: { 25 + id: string; 26 + name: string; 27 + handle: string; 28 + avatarUrl?: string; 29 + description?: string; 30 + }; 31 + cardCount: number; 32 + createdAt: string; 33 + updatedAt: string; 21 34 } 22 35 23 36 export interface GetUrlStatusForMyLibraryResult { ··· 41 54 constructor( 42 55 private cardRepository: ICardRepository, 43 56 private collectionQueryRepository: ICollectionQueryRepository, 57 + private collectionRepo: ICollectionRepository, 58 + private profileService: IProfileService, 44 59 eventPublisher: IEventPublisher, 45 60 ) { 46 61 super(eventPublisher); ··· 96 111 curatorId.value, 97 112 ); 98 113 99 - result.collections = collections.map((collection) => ({ 100 - id: collection.id, 101 - uri: collection.uri, 102 - name: collection.name, 103 - description: collection.description, 104 - })); 114 + // Enrich collections with full data 115 + result.collections = await Promise.all( 116 + collections.map(async (collection) => { 117 + // Fetch full collection to get dates and cardCount 118 + const collectionIdResult = CollectionId.createFromString( 119 + collection.id, 120 + ); 121 + if (collectionIdResult.isErr()) { 122 + throw new Error( 123 + `Invalid collection ID: ${collection.id}`, 124 + ); 125 + } 126 + const collectionResult = await this.collectionRepo.findById( 127 + collectionIdResult.value, 128 + ); 129 + if (collectionResult.isErr()) { 130 + throw new Error( 131 + `Collection not found: ${collection.id}`, 132 + ); 133 + } 134 + const fullCollection = collectionResult.value; 135 + 136 + // Fetch author profile 137 + const authorProfileResult = await this.profileService.getProfile( 138 + fullCollection.curatorId.value, 139 + ); 140 + if (authorProfileResult.isErr()) { 141 + throw new Error( 142 + `Failed to fetch author profile: ${authorProfileResult.error.message}`, 143 + ); 144 + } 145 + const authorProfile = authorProfileResult.value; 146 + 147 + return { 148 + id: collection.id, 149 + uri: collection.uri, 150 + name: collection.name, 151 + description: collection.description, 152 + author: { 153 + id: authorProfile.id, 154 + name: authorProfile.name, 155 + handle: authorProfile.handle, 156 + avatarUrl: authorProfile.avatarUrl, 157 + description: authorProfile.bio, 158 + }, 159 + cardCount: fullCollection.cardCount, 160 + createdAt: fullCollection.createdAt.toISOString(), 161 + updatedAt: fullCollection.updatedAt.toISOString(), 162 + }; 163 + }), 164 + ); 105 165 } catch (error) { 106 166 return err(AppError.UnexpectedError.create(error)); 107 167 }
+23 -1
src/modules/cards/domain/ICardQueryRepository.ts
··· 41 41 urlInLibrary?: boolean; 42 42 createdAt: Date; 43 43 updatedAt: Date; 44 + authorId: string; // NEW - needed to enrich with author profile 44 45 note?: { 45 46 id: string; 46 47 text: string; ··· 61 62 // DTO for single URL card view with library and collection info 62 63 export type UrlCardViewDTO = UrlCardView & WithCollections & WithLibraries; 63 64 65 + // Repository returns card data - will be enriched with user profile in use case 64 66 export interface LibraryForUrlDTO { 65 67 userId: string; 66 - cardId: string; 68 + card: { 69 + id: string; 70 + url: string; 71 + cardContent: { 72 + url: string; 73 + title?: string; 74 + description?: string; 75 + author?: string; 76 + thumbnailUrl?: string; 77 + }; 78 + libraryCount: number; 79 + urlLibraryCount: number; 80 + urlInLibrary?: boolean; 81 + createdAt: Date; 82 + updatedAt: Date; 83 + note?: { 84 + id: string; 85 + text: string; 86 + }; 87 + }; 88 + // Note: userId is the card author in this context (it's their card in their library) 67 89 } 68 90 69 91 // Raw repository DTO - what the repository returns (not enriched)
+6
src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts
··· 73 73 // Raw data for URL card queries 74 74 export interface RawUrlCardData { 75 75 id: string; 76 + authorId: string; 76 77 url: string; 77 78 contentData: any; 78 79 libraryCount: number; ··· 375 376 urlInLibrary: raw.urlInLibrary, 376 377 createdAt: raw.createdAt, 377 378 updatedAt: raw.updatedAt, 379 + authorId: raw.authorId, 378 380 collections: raw.collections, 379 381 note, 380 382 }; ··· 382 384 383 385 public static toCollectionCardQueryResult(raw: { 384 386 id: string; 387 + authorId: string; 385 388 url: string; 386 389 contentData: any; 387 390 libraryCount: number; ··· 421 424 urlInLibrary: raw.urlInLibrary, 422 425 createdAt: raw.createdAt, 423 426 updatedAt: raw.updatedAt, 427 + authorId: raw.authorId, 424 428 note, 425 429 }; 426 430 } ··· 428 432 public static toUrlCardViewDTO(raw: { 429 433 id: string; 430 434 type: string; 435 + authorId: string; 431 436 url: string; 432 437 contentData: UrlContentData; 433 438 libraryCount: number; ··· 475 480 urlInLibrary: raw.urlInLibrary, 476 481 createdAt: raw.createdAt, 477 482 updatedAt: raw.updatedAt, 483 + authorId: raw.authorId, 478 484 collections: raw.inCollections, 479 485 libraries: raw.inLibraries, 480 486 note,
+2
src/modules/cards/infrastructure/repositories/query-services/CollectionCardQueryService.ts
··· 52 52 const cardsQuery = this.db 53 53 .select({ 54 54 id: cards.id, 55 + authorId: cards.authorId, 55 56 url: cards.url, 56 57 contentData: cards.contentData, 57 58 libraryCount: cards.libraryCount, ··· 179 180 180 181 return { 181 182 id: card.id, 183 + authorId: card.authorId, 182 184 url: card.url || '', 183 185 contentData: card.contentData, 184 186 libraryCount: card.libraryCount,
+80 -6
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 34 34 const urlCardsQuery = this.db 35 35 .select({ 36 36 id: cards.id, 37 + authorId: cards.authorId, 37 38 url: cards.url, 38 39 contentData: cards.contentData, 39 40 libraryCount: cards.libraryCount, ··· 179 180 180 181 return { 181 182 id: card.id, 183 + authorId: card.authorId, 182 184 url: card.url || '', 183 185 contentData: card.contentData, 184 186 libraryCount: card.libraryCount, ··· 222 224 .select({ 223 225 id: cards.id, 224 226 type: cards.type, 227 + authorId: cards.authorId, 225 228 url: cards.url, 226 229 contentData: cards.contentData, 227 230 libraryCount: cards.libraryCount, ··· 321 324 const urlCardView = CardMapper.toUrlCardViewDTO({ 322 325 id: card.id, 323 326 type: card.type, 327 + authorId: card.authorId, 324 328 url: card.url || '', 325 329 contentData: card.contentData, 326 330 libraryCount: card.libraryCount, ··· 361 365 const librariesQuery = this.db 362 366 .select({ 363 367 userId: libraryMemberships.userId, 364 - cardId: libraryMemberships.cardId, 368 + cardId: cards.id, 369 + url: cards.url, 370 + contentData: cards.contentData, 371 + libraryCount: cards.libraryCount, 372 + createdAt: cards.createdAt, 373 + updatedAt: cards.updatedAt, 365 374 }) 366 375 .from(libraryMemberships) 367 376 .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) ··· 371 380 372 381 const librariesResult = await librariesQuery; 373 382 374 - // Get total count 383 + // Get total count (needed even if current page is empty) 375 384 const totalCountResult = await this.db 376 385 .select({ count: count() }) 377 386 .from(libraryMemberships) ··· 379 388 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))); 380 389 381 390 const totalCount = totalCountResult[0]?.count || 0; 391 + 392 + if (librariesResult.length === 0) { 393 + return { 394 + items: [], 395 + totalCount, 396 + hasMore: false, 397 + }; 398 + } 399 + 400 + const cardIds = librariesResult.map((lib) => lib.cardId); 401 + 402 + // Get notes for these cards 403 + const notesQuery = this.db 404 + .select({ 405 + id: cards.id, 406 + parentCardId: cards.parentCardId, 407 + contentData: cards.contentData, 408 + }) 409 + .from(cards) 410 + .where( 411 + and( 412 + eq(cards.type, CardTypeEnum.NOTE), 413 + inArray(cards.parentCardId, cardIds), 414 + ), 415 + ); 416 + 417 + const notesResult = await notesQuery; 418 + 419 + // Get urlLibraryCount for this URL 420 + const urlLibraryCountQuery = this.db 421 + .select({ 422 + count: countDistinct(libraryMemberships.userId), 423 + }) 424 + .from(cards) 425 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 426 + .where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, url))); 427 + 428 + const urlLibraryCountResult = await urlLibraryCountQuery; 429 + const urlLibraryCount = urlLibraryCountResult[0]?.count || 0; 430 + 382 431 const hasMore = offset + librariesResult.length < totalCount; 383 432 384 - const items = librariesResult.map((lib) => ({ 385 - userId: lib.userId, 386 - cardId: lib.cardId, 387 - })); 433 + const items: LibraryForUrlDTO[] = librariesResult.map((lib) => { 434 + const note = notesResult.find((n) => n.parentCardId === lib.cardId); 435 + 436 + return { 437 + userId: lib.userId, 438 + card: { 439 + id: lib.cardId, 440 + url: lib.url || '', 441 + cardContent: { 442 + url: lib.contentData?.url, 443 + title: lib.contentData?.metadata?.title, 444 + description: lib.contentData?.metadata?.description, 445 + author: lib.contentData?.metadata?.author, 446 + thumbnailUrl: lib.contentData?.metadata?.imageUrl, 447 + }, 448 + libraryCount: lib.libraryCount, 449 + urlLibraryCount, 450 + urlInLibrary: true, // By definition, if it's in this result, it's in a library 451 + createdAt: lib.createdAt, 452 + updatedAt: lib.updatedAt, 453 + note: note 454 + ? { 455 + id: note.id, 456 + text: note.contentData?.text || '', 457 + } 458 + : undefined, 459 + }, 460 + }; 461 + }); 388 462 389 463 return { 390 464 items,
+4 -4
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
··· 118 118 expect(userIds).toContain(curator3.value); 119 119 120 120 // Check that card IDs are correct 121 - const cardIds = result.items.map((lib) => lib.cardId); 121 + const cardIds = result.items.map((lib) => lib.card.id); 122 122 expect(cardIds).toContain(card1.cardId.getStringValue()); 123 123 expect(cardIds).toContain(card2.cardId.getStringValue()); 124 124 expect(cardIds).toContain(card3.cardId.getStringValue()); ··· 174 174 175 175 expect(result.items).toHaveLength(1); 176 176 expect(result.items[0]!.userId).toBe(curator1.value); 177 - expect(result.items[0]!.cardId).toBe(card1.cardId.getStringValue()); 177 + expect(result.items[0]!.card.id).toBe(card1.cardId.getStringValue()); 178 178 }); 179 179 180 180 it('should not return NOTE cards even if they have the same URL', async () => { ··· 211 211 // Should only return the URL card, not the NOTE card 212 212 expect(result.items).toHaveLength(1); 213 213 expect(result.items[0]!.userId).toBe(curator1.value); 214 - expect(result.items[0]!.cardId).toBe(urlCard.cardId.getStringValue()); 214 + expect(result.items[0]!.card.id).toBe(urlCard.cardId.getStringValue()); 215 215 }); 216 216 217 217 it('should handle multiple cards from same user with same URL', async () => { ··· 253 253 expect(result.items[1]!.userId).toBe(curator1.value); 254 254 255 255 // But different card IDs 256 - const cardIds = result.items.map((lib) => lib.cardId); 256 + const cardIds = result.items.map((lib) => lib.card.id); 257 257 expect(cardIds).toContain(card1.cardId.getStringValue()); 258 258 expect(cardIds).toContain(card2.cardId.getStringValue()); 259 259 });
+50 -171
src/webapp/api-client/types/responses.ts
··· 58 58 metadata: UrlMetadata; 59 59 } 60 60 61 - export interface UrlCardView { 61 + // Unified UrlCard interface - base for all card responses 62 + export interface UrlCard { 62 63 id: string; 63 64 type: 'URL'; 64 65 url: string; ··· 74 75 urlInLibrary?: boolean; 75 76 createdAt: string; 76 77 updatedAt: string; 78 + author: User; 77 79 note?: { 78 80 id: string; 79 81 text: string; 80 82 }; 81 - collections: { 82 - id: string; 83 - name: string; 84 - authorId: string; 85 - }[]; 86 - libraries: { 87 - userId: string; 88 - name: string; 89 - handle: string; 90 - avatarUrl?: string; 91 - }[]; 92 83 } 93 84 94 - export interface GetUrlCardViewResponse extends UrlCardView {} 85 + // Unified Collection interface - used across all endpoints 86 + export interface Collection { 87 + id: string; 88 + uri?: string; 89 + name: string; 90 + author: User; 91 + description?: string; 92 + cardCount: number; 93 + createdAt: string; 94 + updatedAt: string; 95 + } 95 96 96 - export interface LibraryUser { 97 + // Context-specific variations 98 + export interface UrlCardWithCollections extends UrlCard { 99 + collections: Collection[]; 100 + } 101 + 102 + export interface UrlCardWithLibraries extends UrlCard { 103 + libraries: User[]; 104 + } 105 + 106 + export interface UrlCardWithCollectionsAndLibraries extends UrlCard { 107 + collections: Collection[]; 108 + libraries: User[]; 109 + } 110 + 111 + export interface GetUrlCardViewResponse extends UrlCardWithCollectionsAndLibraries {} 112 + 113 + // Unified User interface - used across all endpoints 114 + export interface User { 97 115 id: string; 98 116 name: string; 99 117 handle: string; 100 118 avatarUrl?: string; 119 + description?: string; 101 120 } 102 121 103 122 export interface GetLibrariesForCardResponse { 104 123 cardId: string; 105 - users: LibraryUser[]; 124 + users: User[]; 106 125 totalCount: number; 107 126 } 108 127 109 - export interface UserProfile { 110 - id: string; 111 - name: string; 112 - handle: string; 113 - description?: string; 114 - avatarUrl?: string; 115 - } 128 + export interface GetProfileResponse extends User {} 116 129 117 - export interface GetProfileResponse extends UserProfile {} 118 - 119 - export interface UrlCardListItem { 120 - id: string; 121 - type: 'URL'; 122 - url: string; 123 - cardContent: { 124 - url: string; 125 - title?: string; 126 - description?: string; 127 - author?: string; 128 - thumbnailUrl?: string; 129 - }; 130 - libraryCount: number; 131 - urlLibraryCount: number; 132 - urlInLibrary?: boolean; 133 - createdAt: string; 134 - updatedAt: string; 135 - note?: { 136 - id: string; 137 - text: string; 138 - }; 139 - collections: { 140 - id: string; 141 - name: string; 142 - authorId: string; 143 - }[]; 144 - } 145 130 146 131 // Base pagination interface 147 132 export interface Pagination { ··· 167 152 } 168 153 169 154 export interface GetUrlCardsResponse { 170 - cards: UrlCardListItem[]; 155 + cards: UrlCardWithCollections[]; 171 156 pagination: Pagination; 172 157 sorting: CardSorting; 173 158 } 174 159 175 - export interface CollectionPageUrlCard { 176 - id: string; 177 - type: 'URL'; 178 - url: string; 179 - cardContent: { 180 - url: string; 181 - title?: string; 182 - description?: string; 183 - author?: string; 184 - thumbnailUrl?: string; 185 - }; 186 - libraryCount: number; 187 - urlLibraryCount: number; 188 - urlInLibrary?: boolean; 189 - createdAt: string; 190 - updatedAt: string; 191 - note?: { 192 - id: string; 193 - text: string; 194 - }; 195 - } 196 - 197 160 export interface GetCollectionPageResponse { 198 161 id: string; 199 162 uri?: string; 200 163 name: string; 201 164 description?: string; 202 - author: { 203 - id: string; 204 - name: string; 205 - handle: string; 206 - avatarUrl?: string; 207 - }; 208 - urlCards: CollectionPageUrlCard[]; 165 + author: User; 166 + urlCards: UrlCard[]; 167 + cardCount: number; 168 + createdAt: string; 169 + updatedAt: string; 209 170 pagination: Pagination; 210 171 sorting: CardSorting; 211 172 } 212 173 213 174 export interface GetCollectionsResponse { 214 - collections: { 215 - id: string; 216 - uri?: string; 217 - name: string; 218 - description?: string; 219 - cardCount: number; 220 - createdAt: string; 221 - updatedAt: string; 222 - createdBy: { 223 - id: string; 224 - name: string; 225 - handle: string; 226 - avatarUrl?: string; 227 - }; 228 - }[]; 175 + collections: Collection[]; 229 176 pagination: Pagination; 230 177 sorting: CollectionSorting; 231 178 } ··· 256 203 } 257 204 258 205 // Feed response types 259 - export interface FeedActivityActor { 260 - id: string; 261 - name: string; 262 - handle: string; 263 - avatarUrl?: string; 264 - } 265 - 266 - export interface FeedActivityCard { 267 - id: string; 268 - type: 'URL'; 269 - url: string; 270 - cardContent: { 271 - url: string; 272 - title?: string; 273 - description?: string; 274 - author?: string; 275 - thumbnailUrl?: string; 276 - }; 277 - libraryCount: number; 278 - urlLibraryCount: number; 279 - urlInLibrary?: boolean; 280 - createdAt: string; 281 - updatedAt: string; 282 - note?: { 283 - id: string; 284 - text: string; 285 - }; 286 - collections: { 287 - id: string; 288 - name: string; 289 - authorId: string; 290 - }[]; 291 - libraries: { 292 - userId: string; 293 - name: string; 294 - handle: string; 295 - avatarUrl?: string; 296 - }[]; 297 - } 298 - 299 206 export interface FeedItem { 300 207 id: string; 301 - user: FeedActivityActor; 302 - card: FeedActivityCard; 208 + user: User; 209 + card: UrlCard; 303 210 createdAt: Date; 304 - collections: { 305 - id: string; 306 - name: string; 307 - authorHandle: string; 308 - uri?: string; 309 - }[]; 211 + collections: Collection[]; 310 212 } 311 213 312 214 export interface FeedPagination extends Pagination { ··· 320 222 321 223 export interface GetUrlStatusForMyLibraryResponse { 322 224 cardId?: string; 323 - collections?: { 324 - id: string; 325 - uri?: string; 326 - name: string; 327 - description?: string; 328 - }[]; 225 + collections?: Collection[]; 329 226 } 330 227 331 228 export interface GetLibrariesForUrlResponse { 332 229 libraries: { 333 - userId: string; 334 - name: string; 335 - handle: string; 336 - avatarUrl?: string; 230 + user: User; 231 + card: UrlCard; 337 232 }[]; 338 233 pagination: Pagination; 339 234 sorting: CardSorting; ··· 343 238 notes: { 344 239 id: string; 345 240 note: string; 346 - author: { 347 - id: string; 348 - name: string; 349 - handle: string; 350 - avatarUrl?: string; 351 - }; 241 + author: User; 352 242 createdAt: string; 353 243 updatedAt: string; 354 244 }[]; ··· 357 247 } 358 248 359 249 export interface GetCollectionsForUrlResponse { 360 - collections: { 361 - id: string; 362 - uri?: string; 363 - name: string; 364 - description?: string; 365 - author: { 366 - id: string; 367 - name: string; 368 - handle: string; 369 - avatarUrl?: string; 370 - }; 371 - }[]; 250 + collections: Collection[]; 372 251 pagination: Pagination; 373 252 sorting: CollectionSorting; 374 253 }