This repository has no description
0

Configure Feed

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

Merge branch 'development' into fix/get-url-card-include-author

author
Pouria Delfan
committer
GitHub
date (Oct 28, 2025, 10:43 AM -0700) commit 14e96e9c parent 49d5697e
+1004 -467
+35 -4
src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts
··· 77 77 return err(new ValidationError(`Card not found: ${request.cardId}`)); 78 78 } 79 79 80 - // Remove card from library using domain service 80 + // Remove card from library using domain service (this handles cascading for URL cards) 81 81 const removeFromLibraryResult = 82 82 await this.cardLibraryService.removeCardFromLibrary(card, curatorId); 83 83 if (removeFromLibraryResult.isErr()) { ··· 88 88 } 89 89 90 90 const updatedCard = removeFromLibraryResult.value; 91 + 92 + // Handle deletion with proper ordering for URL cards 91 93 if ( 92 - updatedCard.libraryCount == 0 && 94 + updatedCard.libraryCount === 0 && 93 95 updatedCard.curatorId.equals(curatorId) 94 96 ) { 95 - // If no curators have this card in their library and the curator is the owner, delete the card 96 - const deleteResult = await this.cardRepository.delete(card.cardId); 97 + if (updatedCard.isUrlCard && updatedCard.url) { 98 + // First, delete any associated note card that also has no library memberships 99 + const noteCardResult = 100 + await this.cardRepository.findUsersNoteCardByUrl( 101 + updatedCard.url, 102 + curatorId, 103 + ); 104 + 105 + if (noteCardResult.isOk() && noteCardResult.value) { 106 + const noteCard = noteCardResult.value; 107 + if ( 108 + noteCard.libraryCount === 0 && 109 + noteCard.curatorId.equals(curatorId) 110 + ) { 111 + // Delete note card first (child before parent) 112 + const deleteNoteResult = await this.cardRepository.delete( 113 + noteCard.cardId, 114 + ); 115 + if (deleteNoteResult.isErr()) { 116 + return err( 117 + AppError.UnexpectedError.create(deleteNoteResult.error), 118 + ); 119 + } 120 + } 121 + } 122 + } 123 + 124 + // Then delete the main card (URL card or any other card type) 125 + const deleteResult = await this.cardRepository.delete( 126 + updatedCard.cardId, 127 + ); 97 128 if (deleteResult.isErr()) { 98 129 return err(AppError.UnexpectedError.create(deleteResult.error)); 99 130 }
+98 -54
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts
··· 4 4 import { AppError } from '../../../../../shared/core/AppError'; 5 5 import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 6 6 import { ICardRepository } from '../../../domain/ICardRepository'; 7 + import { ICardQueryRepository } from '../../../domain/ICardQueryRepository'; 7 8 import { ICollectionQueryRepository } from '../../../domain/ICollectionQueryRepository'; 8 9 import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 9 10 import { IProfileService } from '../../../domain/services/IProfileService'; 10 11 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 11 12 import { URL } from '../../../domain/value-objects/URL'; 12 13 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 13 - import { CollectionDTO } from '@semble/types'; 14 + import { CollectionDTO, UrlCard } from '@semble/types'; 14 15 15 16 export interface GetUrlStatusForMyLibraryQuery { 16 17 url: string; ··· 18 19 } 19 20 20 21 export interface GetUrlStatusForMyLibraryResult { 21 - cardId?: string; 22 + card?: UrlCard; 22 23 collections?: CollectionDTO[]; 23 24 } 24 25 ··· 37 38 > { 38 39 constructor( 39 40 private cardRepository: ICardRepository, 41 + private cardQueryRepository: ICardQueryRepository, 40 42 private collectionQueryRepository: ICollectionQueryRepository, 41 43 private collectionRepo: ICollectionRepository, 42 44 private profileService: IProfileService, ··· 85 87 const result: GetUrlStatusForMyLibraryResult = {}; 86 88 87 89 if (card) { 88 - result.cardId = card.cardId.getStringValue(); 90 + // Get enriched card data with note 91 + const cardView = await this.cardQueryRepository.getUrlCardBasic( 92 + card.cardId.getStringValue(), 93 + curatorId.value, 94 + ); 89 95 90 - // Get collections containing this card for the user 91 - try { 92 - const collections = 93 - await this.collectionQueryRepository.getCollectionsContainingCardForUser( 94 - card.cardId.getStringValue(), 95 - curatorId.value, 96 + if (cardView) { 97 + // Get card author profile 98 + const authorProfileResult = await this.profileService.getProfile( 99 + cardView.authorId, 100 + curatorId.value, 101 + ); 102 + 103 + if (authorProfileResult.isErr()) { 104 + return err( 105 + AppError.UnexpectedError.create(authorProfileResult.error), 96 106 ); 107 + } 97 108 98 - // Enrich collections with full data 99 - result.collections = await Promise.all( 100 - collections.map(async (collection): Promise<CollectionDTO> => { 101 - // Fetch full collection to get dates and cardCount 102 - const collectionIdResult = CollectionId.createFromString( 103 - collection.id, 104 - ); 105 - if (collectionIdResult.isErr()) { 106 - throw new Error(`Invalid collection ID: ${collection.id}`); 107 - } 108 - const collectionResult = await this.collectionRepo.findById( 109 - collectionIdResult.value, 110 - ); 111 - if (collectionResult.isErr() || !collectionResult.value) { 112 - throw new Error(`Collection not found: ${collection.id}`); 113 - } 114 - const fullCollection = collectionResult.value; 109 + const authorProfile = authorProfileResult.value; 115 110 116 - // Fetch author profile 117 - const authorProfileResult = await this.profileService.getProfile( 118 - fullCollection.authorId.value, 111 + // Transform to UrlCard format 112 + result.card = { 113 + id: cardView.id, 114 + type: 'URL', 115 + url: cardView.url, 116 + cardContent: cardView.cardContent, 117 + libraryCount: cardView.libraryCount, 118 + urlLibraryCount: cardView.urlLibraryCount, 119 + urlInLibrary: cardView.urlInLibrary, 120 + createdAt: cardView.createdAt.toISOString(), 121 + updatedAt: cardView.updatedAt.toISOString(), 122 + author: { 123 + id: authorProfile.id, 124 + name: authorProfile.name, 125 + handle: authorProfile.handle, 126 + avatarUrl: authorProfile.avatarUrl, 127 + description: authorProfile.bio, 128 + }, 129 + note: cardView.note, // This includes the note if it exists 130 + }; 131 + 132 + // Get collections containing this card for the user 133 + try { 134 + const collections = 135 + await this.collectionQueryRepository.getCollectionsContainingCardForUser( 136 + card.cardId.getStringValue(), 137 + curatorId.value, 119 138 ); 120 - if (authorProfileResult.isErr()) { 121 - throw new Error( 122 - `Failed to fetch author profile: ${authorProfileResult.error.message}`, 139 + 140 + // Enrich collections with full data 141 + result.collections = await Promise.all( 142 + collections.map(async (collection): Promise<CollectionDTO> => { 143 + // Fetch full collection to get dates and cardCount 144 + const collectionIdResult = CollectionId.createFromString( 145 + collection.id, 146 + ); 147 + if (collectionIdResult.isErr()) { 148 + throw new Error(`Invalid collection ID: ${collection.id}`); 149 + } 150 + const collectionResult = await this.collectionRepo.findById( 151 + collectionIdResult.value, 123 152 ); 124 - } 125 - const authorProfile = authorProfileResult.value; 153 + if (collectionResult.isErr() || !collectionResult.value) { 154 + throw new Error(`Collection not found: ${collection.id}`); 155 + } 156 + const fullCollection = collectionResult.value; 126 157 127 - return { 128 - id: collection.id, 129 - uri: collection.uri, 130 - name: collection.name, 131 - description: collection.description, 132 - author: { 133 - id: authorProfile.id, 134 - name: authorProfile.name, 135 - handle: authorProfile.handle, 136 - avatarUrl: authorProfile.avatarUrl, 137 - description: authorProfile.bio, 138 - }, 139 - cardCount: fullCollection.cardCount, 140 - createdAt: fullCollection.createdAt.toISOString(), 141 - updatedAt: fullCollection.updatedAt.toISOString(), 142 - }; 143 - }), 144 - ); 145 - } catch (error) { 146 - return err(AppError.UnexpectedError.create(error)); 158 + // Fetch author profile 159 + const authorProfileResult = 160 + await this.profileService.getProfile( 161 + fullCollection.authorId.value, 162 + ); 163 + if (authorProfileResult.isErr()) { 164 + throw new Error( 165 + `Failed to fetch author profile: ${authorProfileResult.error.message}`, 166 + ); 167 + } 168 + const authorProfile = authorProfileResult.value; 169 + 170 + return { 171 + id: collection.id, 172 + uri: collection.uri, 173 + name: collection.name, 174 + description: collection.description, 175 + author: { 176 + id: authorProfile.id, 177 + name: authorProfile.name, 178 + handle: authorProfile.handle, 179 + avatarUrl: authorProfile.avatarUrl, 180 + description: authorProfile.bio, 181 + }, 182 + cardCount: fullCollection.cardCount, 183 + createdAt: fullCollection.createdAt.toISOString(), 184 + updatedAt: fullCollection.updatedAt.toISOString(), 185 + }; 186 + }), 187 + ); 188 + } catch (error) { 189 + return err(AppError.UnexpectedError.create(error)); 190 + } 147 191 } 148 192 } 149 193
+5
src/modules/cards/domain/ICardQueryRepository.ts
··· 129 129 callingUserId?: string, 130 130 ): Promise<UrlCardViewDTO | null>; 131 131 132 + getUrlCardBasic( 133 + cardId: string, 134 + callingUserId?: string, 135 + ): Promise<UrlCardView | null>; 136 + 132 137 getLibrariesForCard(cardId: string): Promise<string[]>; 133 138 134 139 getLibrariesForUrl(
+25
src/modules/cards/domain/services/CardLibraryService.ts
··· 155 155 } 156 156 } 157 157 158 + // Handle cascading removal for URL cards 159 + if (card.isUrlCard && card.url) { 160 + const noteCardResult = await this.cardRepository.findUsersNoteCardByUrl( 161 + card.url, 162 + curatorId, 163 + ); 164 + 165 + if (noteCardResult.isOk() && noteCardResult.value) { 166 + const noteCard = noteCardResult.value; 167 + 168 + // Recursively remove note card from library (this will handle its unpublishing) 169 + const removeNoteResult = await this.removeCardFromLibrary( 170 + noteCard, 171 + curatorId, 172 + ); 173 + if (removeNoteResult.isErr()) { 174 + return err( 175 + new CardLibraryValidationError( 176 + `Failed to remove associated note card: ${removeNoteResult.error.message}`, 177 + ), 178 + ); 179 + } 180 + } 181 + } 182 + 158 183 // Get library info to check if it was published 159 184 const libraryInfo = card.getLibraryInfo(curatorId); 160 185 if (libraryInfo?.publishedRecordId) {
+8
src/modules/cards/infrastructure/repositories/DrizzleCardQueryRepository.ts
··· 8 8 UrlCardViewDTO, 9 9 LibraryForUrlDTO, 10 10 NoteCardForUrlRawDTO, 11 + UrlCardView, 11 12 } from '../../domain/ICardQueryRepository'; 12 13 import { UrlCardQueryService } from './query-services/UrlCardQueryService'; 13 14 import { CollectionCardQueryService } from './query-services/CollectionCardQueryService'; ··· 56 57 callingUserId?: string, 57 58 ): Promise<UrlCardViewDTO | null> { 58 59 return this.urlCardQueryService.getUrlCardView(cardId, callingUserId); 60 + } 61 + 62 + async getUrlCardBasic( 63 + cardId: string, 64 + callingUserId?: string, 65 + ): Promise<UrlCardView | null> { 66 + return this.urlCardQueryService.getUrlCardBasic(cardId, callingUserId); 59 67 } 60 68 61 69 async getLibrariesForCard(cardId: string): Promise<string[]> {
+110
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 1 1 import { eq, desc, asc, count, countDistinct, inArray, and } from 'drizzle-orm'; 2 + import { UrlCardView } from '../../../domain/ICardQueryRepository'; 2 3 import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 4 import { 4 5 CardQueryOptions, ··· 467 468 }; 468 469 } catch (error) { 469 470 console.error('Error in getLibrariesForUrl:', error); 471 + throw error; 472 + } 473 + } 474 + 475 + async getUrlCardBasic( 476 + cardId: string, 477 + callingUserId?: string, 478 + ): Promise<UrlCardView | null> { 479 + try { 480 + // Get the URL card 481 + const cardQuery = this.db 482 + .select({ 483 + id: cards.id, 484 + type: cards.type, 485 + authorId: cards.authorId, 486 + url: cards.url, 487 + contentData: cards.contentData, 488 + libraryCount: cards.libraryCount, 489 + createdAt: cards.createdAt, 490 + updatedAt: cards.updatedAt, 491 + }) 492 + .from(cards) 493 + .where(and(eq(cards.id, cardId), eq(cards.type, CardTypeEnum.URL))); 494 + 495 + const cardResult = await cardQuery; 496 + 497 + if (cardResult.length === 0) { 498 + return null; 499 + } 500 + 501 + const card = cardResult[0]!; 502 + 503 + // Get note card for this URL card (same user, parentCardId matches, type = NOTE) 504 + const noteQuery = this.db 505 + .select({ 506 + id: cards.id, 507 + parentCardId: cards.parentCardId, 508 + contentData: cards.contentData, 509 + }) 510 + .from(cards) 511 + .where( 512 + and( 513 + eq(cards.type, CardTypeEnum.NOTE), 514 + eq(cards.parentCardId, cardId), 515 + eq(cards.authorId, card.authorId), // Only notes by the same author 516 + ), 517 + ) 518 + .limit(1); // Only get the first note if multiple exist 519 + 520 + const noteResult = await noteQuery; 521 + const note = noteResult.length > 0 ? noteResult[0] : undefined; 522 + 523 + // Get urlLibraryCount for this URL (count of unique users who have cards with this URL) 524 + const urlLibraryCountQuery = this.db 525 + .select({ 526 + count: countDistinct(libraryMemberships.userId), 527 + }) 528 + .from(cards) 529 + .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 530 + .where(and(eq(cards.type, CardTypeEnum.URL), eq(cards.url, card.url))); 531 + 532 + const urlLibraryCountResult = await urlLibraryCountQuery; 533 + const urlLibraryCount = urlLibraryCountResult[0]?.count || 0; 534 + 535 + // Get urlInLibrary if callingUserId is provided 536 + let urlInLibrary: boolean | undefined; 537 + if (callingUserId) { 538 + // Check if the calling user has any card with this URL 539 + const urlInLibraryQuery = this.db 540 + .select({ 541 + id: cards.id, 542 + }) 543 + .from(cards) 544 + .where( 545 + and( 546 + eq(cards.authorId, callingUserId), 547 + eq(cards.type, CardTypeEnum.URL), 548 + eq(cards.url, card.url), 549 + ), 550 + ) 551 + .limit(1); 552 + 553 + const urlInLibraryResult = await urlInLibraryQuery; 554 + urlInLibrary = urlInLibraryResult.length > 0; 555 + } 556 + 557 + // Create raw card data for mapping 558 + const rawCardData = { 559 + id: card.id, 560 + authorId: card.authorId, 561 + url: card.url || '', 562 + contentData: card.contentData, 563 + libraryCount: card.libraryCount, 564 + urlLibraryCount, 565 + urlInLibrary, 566 + createdAt: card.createdAt, 567 + updatedAt: card.updatedAt, 568 + note: note 569 + ? { 570 + id: note.id, 571 + contentData: note.contentData, 572 + } 573 + : undefined, 574 + }; 575 + 576 + // Use CardMapper to transform to UrlCardView (without collections) 577 + return CardMapper.toCollectionCardQueryResult(rawCardData); 578 + } catch (error) { 579 + console.error('Error in getUrlCardBasic:', error); 470 580 throw error; 471 581 } 472 582 }
+1
src/modules/cards/tests/application/GetCollectionPageUseCase.test.ts
··· 755 755 .fn() 756 756 .mockRejectedValue(new Error('Query failed')), 757 757 getUrlCardView: jest.fn(), 758 + getUrlCardBasic: jest.fn(), 758 759 getLibrariesForCard: jest.fn(), 759 760 getLibrariesForUrl: jest.fn(), 760 761 getNoteCardsForUrl: jest.fn(),
+1
src/modules/cards/tests/application/GetLibrariesForUrlUseCase.test.ts
··· 363 363 getUrlCardsOfUser: jest.fn(), 364 364 getCardsInCollection: jest.fn(), 365 365 getUrlCardView: jest.fn(), 366 + getUrlCardBasic: jest.fn(), 366 367 getLibrariesForCard: jest.fn(), 367 368 getLibrariesForUrl: jest 368 369 .fn()
+1
src/modules/cards/tests/application/GetMyUrlCardsUseCase.test.ts
··· 670 670 getUrlCardView: jest 671 671 .fn() 672 672 .mockRejectedValue(new Error('Database connection failed')), 673 + getUrlCardBasic: jest.fn(), 673 674 getLibrariesForCard: jest.fn(), 674 675 getLibrariesForUrl: jest.fn(), 675 676 getNoteCardsForUrl: jest.fn(),
+1
src/modules/cards/tests/application/GetUrlCardViewUseCase.test.ts
··· 478 478 getUrlCardView: jest 479 479 .fn() 480 480 .mockRejectedValue(new Error('Database connection failed')), 481 + getUrlCardBasic: jest.fn(), 481 482 getLibrariesForCard: jest.fn(), 482 483 getLibrariesForUrl: jest.fn(), 483 484 getNoteCardsForUrl: jest.fn(),
+19 -5
src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts
··· 1 1 import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 2 2 import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3 + import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; 3 4 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 4 5 import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; 5 6 import { FakeCardPublisher } from '../utils/FakeCardPublisher'; ··· 18 19 describe('GetUrlStatusForMyLibraryUseCase', () => { 19 20 let useCase: GetUrlStatusForMyLibraryUseCase; 20 21 let cardRepository: InMemoryCardRepository; 22 + let cardQueryRepository: InMemoryCardQueryRepository; 21 23 let collectionRepository: InMemoryCollectionRepository; 22 24 let collectionQueryRepository: InMemoryCollectionQueryRepository; 23 25 let cardPublisher: FakeCardPublisher; ··· 30 32 beforeEach(() => { 31 33 cardRepository = InMemoryCardRepository.getInstance(); 32 34 collectionRepository = InMemoryCollectionRepository.getInstance(); 35 + cardQueryRepository = new InMemoryCardQueryRepository( 36 + cardRepository, 37 + collectionRepository, 38 + ); 33 39 collectionQueryRepository = new InMemoryCollectionQueryRepository( 34 40 collectionRepository, 35 41 ); ··· 40 46 41 47 useCase = new GetUrlStatusForMyLibraryUseCase( 42 48 cardRepository, 49 + cardQueryRepository, 43 50 collectionQueryRepository, 44 51 collectionRepository, 45 52 profileService, ··· 69 76 70 77 afterEach(() => { 71 78 cardRepository.clear(); 79 + cardQueryRepository.clear(); 72 80 collectionRepository.clear(); 73 81 collectionQueryRepository.clear(); 74 82 cardPublisher.clear(); ··· 213 221 expect(result.isOk()).toBe(true); 214 222 const response = result.unwrap(); 215 223 216 - expect(response.cardId).toBe(card.cardId.getStringValue()); 224 + expect(response.card).toBeDefined(); 225 + expect(response.card?.id).toBe(card.cardId.getStringValue()); 217 226 expect(response.collections).toHaveLength(2); 218 227 219 228 // Verify collection details ··· 289 298 expect(result.isOk()).toBe(true); 290 299 const response = result.unwrap(); 291 300 292 - expect(response.cardId).toBe(card.cardId.getStringValue()); 301 + expect(response.card).toBeDefined(); 302 + expect(response.card?.id).toBe(card.cardId.getStringValue()); 293 303 expect(response.collections).toHaveLength(0); 294 304 }); 295 305 ··· 308 318 expect(result.isOk()).toBe(true); 309 319 const response = result.unwrap(); 310 320 311 - expect(response.cardId).toBeUndefined(); 321 + expect(response.card).toBeUndefined(); 312 322 expect(response.collections).toBeUndefined(); 313 323 }); 314 324 ··· 392 402 expect(result.isOk()).toBe(true); 393 403 const response = result.unwrap(); 394 404 395 - expect(response.cardId).toBe(card1.cardId.getStringValue()); 405 + expect(response.card).toBeDefined(); 406 + expect(response.card?.id).toBe(card1.cardId.getStringValue()); 396 407 expect(response.collections).toHaveLength(0); // No collections for first user 397 408 }); 398 409 ··· 473 484 expect(result.isOk()).toBe(true); 474 485 const response = result.unwrap(); 475 486 476 - expect(response.cardId).toBe(card.cardId.getStringValue()); 487 + expect(response.card).toBeDefined(); 488 + expect(response.card?.id).toBe(card.cardId.getStringValue()); 477 489 expect(response.collections).toHaveLength(1); 478 490 expect(response.collections?.[0]?.name).toBe('My Collection'); 479 491 expect(response.collections?.[0]?.id).toBe( ··· 527 539 528 540 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 529 541 errorCardRepository, 542 + cardQueryRepository, 530 543 collectionQueryRepository, 531 544 collectionRepository, 532 545 profileService, ··· 581 594 582 595 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 583 596 cardRepository, 597 + cardQueryRepository, 584 598 errorCollectionQueryRepository, 585 599 collectionRepository, 586 600 profileService,
+34
src/modules/cards/tests/application/RemoveCardFromLibraryUseCase.test.ts
··· 646 646 const updatedCard = updatedCardResult.unwrap(); 647 647 expect(updatedCard).toBeNull(); 648 648 }); 649 + 650 + it('should delete URL card and associated note card when both have no library memberships', async () => { 651 + const urlCard = await createCard(CardTypeEnum.URL, curatorId); 652 + await addCardToLibrary(urlCard, curatorId); 653 + 654 + // Create associated note card 655 + const noteCard = new CardBuilder() 656 + .withType(CardTypeEnum.NOTE) 657 + .withCuratorId(curatorId.value) 658 + .withParentCard(urlCard.cardId) 659 + .withUrl(urlCard.url!) 660 + .build(); 661 + 662 + if (noteCard instanceof Error) throw noteCard; 663 + 664 + await cardRepository.save(noteCard); 665 + await addCardToLibrary(noteCard, curatorId); 666 + 667 + // Remove URL card from library 668 + const request = { 669 + cardId: urlCard.cardId.getStringValue(), 670 + curatorId: curatorId.value, 671 + }; 672 + 673 + const result = await useCase.execute(request); 674 + expect(result.isOk()).toBe(true); 675 + 676 + // Verify both cards were deleted 677 + const urlCardResult = await cardRepository.findById(urlCard.cardId); 678 + const noteCardResult = await cardRepository.findById(noteCard.cardId); 679 + 680 + expect(urlCardResult.unwrap()).toBeNull(); 681 + expect(noteCardResult.unwrap()).toBeNull(); 682 + }); 649 683 }); 650 684 });
+211
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getUrlCardBasic.integration.test.ts
··· 1 + import { 2 + PostgreSqlContainer, 3 + StartedPostgreSqlContainer, 4 + } from '@testcontainers/postgresql'; 5 + import postgres from 'postgres'; 6 + import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 7 + import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository'; 8 + import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; 9 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 10 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 11 + import { cards } from '../../infrastructure/repositories/schema/card.sql'; 12 + import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 13 + import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 14 + import { CardBuilder } from '../utils/builders/CardBuilder'; 15 + import { URL } from '../../domain/value-objects/URL'; 16 + import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; 17 + import { createTestSchema } from '../test-utils/createTestSchema'; 18 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 19 + 20 + describe('DrizzleCardQueryRepository - getUrlCardBasic', () => { 21 + let container: StartedPostgreSqlContainer; 22 + let db: PostgresJsDatabase; 23 + let queryRepository: DrizzleCardQueryRepository; 24 + let cardRepository: DrizzleCardRepository; 25 + 26 + // Test data 27 + let curatorId: CuratorId; 28 + let otherCuratorId: CuratorId; 29 + 30 + // Setup before all tests 31 + beforeAll(async () => { 32 + // Start PostgreSQL container 33 + container = await new PostgreSqlContainer('postgres:14').start(); 34 + 35 + // Create database connection 36 + const connectionString = container.getConnectionUri(); 37 + process.env.DATABASE_URL = connectionString; 38 + const client = postgres(connectionString); 39 + db = drizzle(client); 40 + 41 + // Create repositories 42 + queryRepository = new DrizzleCardQueryRepository(db); 43 + cardRepository = new DrizzleCardRepository(db); 44 + 45 + // Create schema using helper function 46 + await createTestSchema(db); 47 + 48 + // Create test data 49 + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 50 + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 51 + }, 60000); // Increase timeout for container startup 52 + 53 + // Cleanup after all tests 54 + afterAll(async () => { 55 + // Stop container 56 + await container.stop(); 57 + }); 58 + 59 + // Clear data between tests 60 + beforeEach(async () => { 61 + await db.delete(libraryMemberships); 62 + await db.delete(cards); 63 + await db.delete(publishedRecords); 64 + }); 65 + 66 + describe('getUrlCardBasic', () => { 67 + it('should return null when card does not exist', async () => { 68 + const nonExistentCardId = new UniqueEntityID().toString(); 69 + 70 + const result = await queryRepository.getUrlCardBasic(nonExistentCardId); 71 + 72 + expect(result).toBeNull(); 73 + }); 74 + 75 + it('should return null when card exists but is not a URL card', async () => { 76 + // Create a note card 77 + const noteCard = new CardBuilder() 78 + .withCuratorId(curatorId.value) 79 + .withNoteCard('This is a note') 80 + .buildOrThrow(); 81 + 82 + await cardRepository.save(noteCard); 83 + 84 + const result = await queryRepository.getUrlCardBasic( 85 + noteCard.cardId.getStringValue(), 86 + ); 87 + 88 + expect(result).toBeNull(); 89 + }); 90 + 91 + it('should return URL card with basic metadata', async () => { 92 + // Create URL card with metadata 93 + const url = URL.create('https://example.com/article').unwrap(); 94 + const urlMetadata = UrlMetadata.create({ 95 + url: url.value, 96 + title: 'Test Article', 97 + description: 'A test article description', 98 + author: 'John Doe', 99 + imageUrl: 'https://example.com/image.jpg', 100 + }).unwrap(); 101 + 102 + const urlCard = new CardBuilder() 103 + .withCuratorId(curatorId.value) 104 + .withUrlCard(url, urlMetadata) 105 + .buildOrThrow(); 106 + 107 + await cardRepository.save(urlCard); 108 + 109 + const result = await queryRepository.getUrlCardBasic( 110 + urlCard.cardId.getStringValue(), 111 + ); 112 + 113 + expect(result).toBeDefined(); 114 + expect(result?.id).toBe(urlCard.cardId.getStringValue()); 115 + expect(result?.type).toBe(CardTypeEnum.URL); 116 + expect(result?.url).toBe(url.value); 117 + expect(result?.cardContent.title).toBe('Test Article'); 118 + expect(result?.cardContent.description).toBe( 119 + 'A test article description', 120 + ); 121 + expect(result?.cardContent.author).toBe('John Doe'); 122 + expect(result?.cardContent.thumbnailUrl).toBe( 123 + 'https://example.com/image.jpg', 124 + ); 125 + expect(result?.note).toBeUndefined(); 126 + }); 127 + 128 + it('should include connected note card by the same author', async () => { 129 + // Create URL card 130 + const url = URL.create('https://example.com/article-with-note').unwrap(); 131 + const urlCard = new CardBuilder() 132 + .withCuratorId(curatorId.value) 133 + .withUrlCard(url) 134 + .buildOrThrow(); 135 + 136 + await cardRepository.save(urlCard); 137 + 138 + // Create connected note card by the same author 139 + const noteCard = new CardBuilder() 140 + .withCuratorId(curatorId.value) 141 + .withNoteCard('This is my note about the article') 142 + .withParentCard(urlCard.cardId) 143 + .buildOrThrow(); 144 + 145 + await cardRepository.save(noteCard); 146 + 147 + const result = await queryRepository.getUrlCardBasic( 148 + urlCard.cardId.getStringValue(), 149 + ); 150 + 151 + expect(result).toBeDefined(); 152 + expect(result?.note).toBeDefined(); 153 + expect(result?.note?.id).toBe(noteCard.cardId.getStringValue()); 154 + expect(result?.note?.text).toBe('This is my note about the article'); 155 + }); 156 + 157 + it('should NOT include note card by a different author', async () => { 158 + // Create URL card by first author 159 + const url = URL.create( 160 + 'https://example.com/article-different-author', 161 + ).unwrap(); 162 + const urlCard = new CardBuilder() 163 + .withCuratorId(curatorId.value) 164 + .withUrlCard(url) 165 + .buildOrThrow(); 166 + 167 + await cardRepository.save(urlCard); 168 + 169 + // Create connected note card by a DIFFERENT author 170 + const noteCard = new CardBuilder() 171 + .withCuratorId(otherCuratorId.value) // Different author 172 + .withNoteCard('This is someone elses note') 173 + .withParentCard(urlCard.cardId) 174 + .buildOrThrow(); 175 + 176 + await cardRepository.save(noteCard); 177 + 178 + const result = await queryRepository.getUrlCardBasic( 179 + urlCard.cardId.getStringValue(), 180 + ); 181 + 182 + expect(result).toBeDefined(); 183 + expect(result?.note).toBeUndefined(); // Should not include note from different author 184 + }); 185 + 186 + it('should handle URL card without metadata', async () => { 187 + // Create URL card without metadata 188 + const url = URL.create('https://example.com/minimal').unwrap(); 189 + const urlCard = new CardBuilder() 190 + .withCuratorId(curatorId.value) 191 + .withUrlCard(url) 192 + .buildOrThrow(); 193 + 194 + await cardRepository.save(urlCard); 195 + 196 + const result = await queryRepository.getUrlCardBasic( 197 + urlCard.cardId.getStringValue(), 198 + ); 199 + 200 + expect(result).toBeDefined(); 201 + expect(result?.id).toBe(urlCard.cardId.getStringValue()); 202 + expect(result?.type).toBe(CardTypeEnum.URL); 203 + expect(result?.url).toBe(url.value); 204 + expect(result?.cardContent.title).toBeUndefined(); 205 + expect(result?.cardContent.description).toBeUndefined(); 206 + expect(result?.cardContent.author).toBeUndefined(); 207 + expect(result?.cardContent.thumbnailUrl).toBeUndefined(); 208 + expect(result?.note).toBeUndefined(); 209 + }); 210 + }); 211 + });
+57
src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts
··· 4 4 UrlCardQueryResultDTO, 5 5 CollectionCardQueryResultDTO, 6 6 UrlCardViewDTO, 7 + UrlCardView, 7 8 PaginatedQueryResult, 8 9 CardSortField, 9 10 SortOrder, ··· 345 346 return { 346 347 ...urlCardResult, 347 348 libraries, 349 + note, 350 + }; 351 + } 352 + 353 + async getUrlCardBasic( 354 + cardId: string, 355 + callingUserId?: string, 356 + ): Promise<UrlCardView | null> { 357 + const allCards = this.cardRepository.getAllCards(); 358 + const card = allCards.find((c) => c.cardId.getStringValue() === cardId); 359 + if (!card || !card.isUrlCard) { 360 + return null; 361 + } 362 + 363 + // Find note card by the same author with matching parent card ID 364 + const noteCard = allCards.find( 365 + (c) => 366 + c.type.value === 'NOTE' && 367 + c.parentCardId?.equals(card.cardId) && 368 + c.curatorId.value === card.curatorId.value, // Only notes by the same author 369 + ); 370 + 371 + const note = noteCard 372 + ? { 373 + id: noteCard.cardId.getStringValue(), 374 + text: noteCard.content.noteContent?.text || '', 375 + } 376 + : undefined; 377 + 378 + // Compute urlInLibrary if callingUserId is provided 379 + const urlInLibrary = callingUserId 380 + ? this.isUrlInUserLibrary( 381 + card.content.urlContent!.url.value, 382 + callingUserId, 383 + ) 384 + : undefined; 385 + 386 + return { 387 + id: card.cardId.getStringValue(), 388 + type: CardTypeEnum.URL, 389 + url: card.content.urlContent!.url.value, 390 + cardContent: { 391 + url: card.content.urlContent!.url.value, 392 + title: card.content.urlContent!.metadata?.title, 393 + description: card.content.urlContent!.metadata?.description, 394 + author: card.content.urlContent!.metadata?.author, 395 + thumbnailUrl: card.content.urlContent!.metadata?.imageUrl, 396 + }, 397 + libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()), 398 + urlLibraryCount: this.getUrlLibraryCount( 399 + card.content.urlContent!.url.value, 400 + ), 401 + urlInLibrary, 402 + createdAt: card.createdAt, 403 + updatedAt: card.updatedAt, 404 + authorId: card.curatorId.value, 348 405 note, 349 406 }; 350 407 }
-4
src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts
··· 11 11 } from '../../../../cards/domain/ICardQueryRepository'; 12 12 import { ICollectionRepository } from 'src/modules/cards/domain/ICollectionRepository'; 13 13 import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 14 - import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; 15 - import { DID } from '../../../../atproto/domain/DID'; 16 - import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; 17 14 import { GetGlobalFeedResponse, FeedItem } from '@semble/types'; 18 15 19 16 export interface GetGlobalFeedQuery { ··· 44 41 private profileService: IProfileService, 45 42 private cardQueryRepository: ICardQueryRepository, 46 43 private collectionRepository: ICollectionRepository, 47 - private identityResolutionService: IIdentityResolutionService, 48 44 ) {} 49 45 50 46 async execute(
+1 -1
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 200 200 ), 201 201 getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( 202 202 repositories.cardRepository, 203 + repositories.cardQueryRepository, 203 204 repositories.collectionQueryRepository, 204 205 repositories.collectionRepository, 205 206 services.profileService, ··· 225 226 services.profileService, 226 227 repositories.cardQueryRepository, 227 228 repositories.collectionRepository, 228 - services.identityResolutionService, 229 229 ), 230 230 addActivityToFeedUseCase: new AddActivityToFeedUseCase( 231 231 services.feedService,
+1 -1
src/types/src/api/responses.ts
··· 192 192 } 193 193 194 194 export interface GetUrlStatusForMyLibraryResponse { 195 - cardId?: string; 195 + card?: UrlCard; 196 196 collections?: Collection[]; 197 197 } 198 198
+2 -9
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 1 + import { getProfile } from '@/features/profile/lib/dal'; 2 2 import type { Metadata } from 'next'; 3 3 import { Fragment } from 'react'; 4 4 ··· 9 9 10 10 export async function generateMetadata({ params }: Props): Promise<Metadata> { 11 11 const { handle } = await params; 12 - 13 - const apiClient = new ApiClient( 14 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 15 - ); 16 - 17 - const profile = await apiClient.getProfile({ 18 - identifier: handle, 19 - }); 12 + const profile = await getProfile(handle); 20 13 21 14 return { 22 15 title: `${profile.name}'s cards`,
+2 -9
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 1 + import { getProfile } from '@/features/profile/lib/dal'; 2 2 import type { Metadata } from 'next'; 3 3 import { Fragment } from 'react'; 4 4 ··· 9 9 10 10 export async function generateMetadata({ params }: Props): Promise<Metadata> { 11 11 const { handle } = await params; 12 - 13 - const apiClient = new ApiClient( 14 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 15 - ); 16 - 17 - const profile = await apiClient.getProfile({ 18 - identifier: handle, 19 - }); 12 + const profile = await getProfile(handle); 20 13 21 14 return { 22 15 title: `${profile.name}'s collections`,
+2 -9
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx
··· 4 4 import ProfileTabs from '@/features/profile/components/profileTabs/ProfileTabs'; 5 5 import { Box, Container } from '@mantine/core'; 6 6 import { Fragment, Suspense } from 'react'; 7 - import { ApiClient } from '@/api-client/ApiClient'; 8 7 import ProfileHeaderSkeleton from '@/features/profile/components/profileHeader/Skeleton.ProfileHeader'; 9 8 import BackButton from '@/components/navigation/backButton/BackButton'; 9 + import { getProfile } from '@/features/profile/lib/dal'; 10 10 11 11 interface Props { 12 12 params: Promise<{ handle: string }>; ··· 15 15 16 16 export async function generateMetadata({ params }: Props): Promise<Metadata> { 17 17 const { handle } = await params; 18 - 19 - const apiClient = new ApiClient( 20 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 21 - ); 22 - 23 - const profile = await apiClient.getProfile({ 24 - identifier: handle, 25 - }); 18 + const profile = await getProfile(handle); 26 19 27 20 return { 28 21 title: profile.name,
+2 -7
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx
··· 1 - import { ApiClient } from '@/api-client'; 2 1 import OpenGraphCard from '@/features/openGraph/components/openGraphCard/OpenGraphCard'; 2 + import { getProfile } from '@/features/profile/lib/dal'; 3 3 import { truncateText } from '@/lib/utils/text'; 4 4 5 5 interface Props { ··· 14 14 15 15 export default async function Image(props: Props) { 16 16 const { handle } = await props.params; 17 - 18 - const apiClient = new ApiClient( 19 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 20 - ); 21 - 22 - const profile = await apiClient.getProfile({ identifier: handle }); 17 + const profile = await getProfile(handle); 23 18 24 19 return await OpenGraphCard({ 25 20 children: (
+1 -19
src/webapp/app/api/auth/me/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { cookies } from 'next/headers'; 3 - 4 - // Helper to check if token is expired or will expire soon 5 - function isTokenExpiringSoon( 6 - token: string | null | undefined, 7 - bufferMinutes: number = 5, 8 - ): boolean { 9 - if (!token) return true; 10 - 11 - try { 12 - const payload = JSON.parse( 13 - Buffer.from(token.split('.')[1], 'base64').toString(), 14 - ); 15 - const expiry = payload.exp * 1000; 16 - const bufferTime = bufferMinutes * 60 * 1000; 17 - return Date.now() >= expiry - bufferTime; 18 - } catch { 19 - return true; 20 - } 21 - } 3 + import { isTokenExpiringSoon } from '@/lib/auth/token'; 22 4 23 5 export async function GET(request: NextRequest) { 24 6 try {
+7 -2
src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx
··· 1 1 'use client'; 2 2 3 3 import { ReactNode, useEffect, startTransition, useRef } from 'react'; 4 - import { Center, Button, Stack, Text } from '@mantine/core'; 4 + import { Center, Button, Stack, Text, Loader } from '@mantine/core'; 5 5 import { useIntersection } from '@mantine/hooks'; 6 6 7 7 interface Props { ··· 35 35 return ( 36 36 <Stack> 37 37 {props.children} 38 - {props.isLoading && props.loader} 38 + {props.isLoading && 39 + (props.loader || ( 40 + <Center> 41 + <Loader /> 42 + </Center> 43 + ))} 39 44 40 45 <Center ref={ref}> 41 46 {!props.isLoading && props.hasMore && props.manualLoadButton && (
+2 -1
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 1 + 'use client'; 2 + 1 3 import { AppShell } from '@mantine/core'; 2 4 import Navbar from '@/components/navigation/navbar/Navbar'; 3 5 import ComposerDrawer from '@/features/composer/components/composerDrawer/ComposerDrawer'; ··· 32 34 collapsed: { mobile: true }, 33 35 }} 34 36 > 35 - {/*<Header />*/} 36 37 <Navbar /> 37 38 38 39 <AppShell.Main>
+1 -1
src/webapp/components/navigation/navbar/Navbar.tsx
··· 34 34 <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> 35 35 <Group justify="space-between"> 36 36 <Anchor component={Link} href={'/home'}> 37 - <Stack align="center" gap={'xs'}> 37 + <Stack align="center" gap={6}> 38 38 <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 39 39 <Badge size="xs">Alpha</Badge> 40 40 </Stack>
+1 -1
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 1 1 import { 2 - Button, 2 + Button, 3 3 Container, 4 4 Drawer, 5 5 Group,
+8 -128
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 1 1 import type { UrlCard } from '@/api-client'; 2 2 import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 3 - import { Anchor, Modal, Stack, Text } from '@mantine/core'; 4 - import { notifications } from '@mantine/notifications'; 5 - import { Suspense, useState } from 'react'; 6 - import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; 7 - import CardToBeAddedPreview from '../cardToBeAddedPreview/CardToBeAddedPreview'; 8 - import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; 9 - import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 10 - import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; 11 - import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations'; 3 + import { Modal, Stack, Text } from '@mantine/core'; 4 + import { Suspense } from 'react'; 12 5 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 13 - import useAddCard from '../../lib/mutations/useAddCard'; 6 + import AddCardToModalContent from './AddCardToModalContent'; // new file or inline 14 7 15 8 interface Props { 16 9 isOpen: boolean; ··· 23 16 } 24 17 25 18 export default function AddCardToModal(props: Props) { 26 - const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 27 - const isMyCard = props.cardId === cardStatus.data.cardId; 28 - const [note, setNote] = useState(props.note); 29 - const { data, error } = useMyCollections(); 30 - 31 - const allCollections = 32 - data?.pages.flatMap((page) => page.collections ?? []) ?? []; 33 - 34 - const collectionsWithCard = allCollections.filter((c) => 35 - cardStatus.data.collections?.some((col) => col.id === c.id), 36 - ); 37 - 38 - const [selectedCollections, setSelectedCollections] = 39 - useState<SelectableCollectionItem[]>(collectionsWithCard); 40 - 41 - const addCard = useAddCard(); 42 - const updateCardAssociations = useUpdateCardAssociations(); 43 - 44 - const handleUpdateCard = (e: React.FormEvent) => { 45 - e.preventDefault(); 46 - 47 - const trimmedNote = note?.trimEnd() === '' ? undefined : note; 48 - 49 - const addedCollections = selectedCollections.filter( 50 - (c) => !collectionsWithCard.some((original) => original.id === c.id), 51 - ); 52 - 53 - const removedCollections = collectionsWithCard.filter( 54 - (c) => !selectedCollections.some((selected) => selected.id === c.id), 55 - ); 56 - 57 - const hasNoteChanged = trimmedNote !== props.note; 58 - const hasAdded = addedCollections.length > 0; 59 - const hasRemoved = removedCollections.length > 0; 60 - 61 - if (cardStatus.data.cardId && !hasNoteChanged && !hasAdded && !hasRemoved) { 62 - props.onClose(); 63 - return; 64 - } 65 - 66 - // if the card is not in library, add it instead of updating 67 - if (!cardStatus.data.cardId) { 68 - addCard.mutate( 69 - { 70 - url: props.cardContent.url, 71 - note: trimmedNote, 72 - collectionIds: selectedCollections.map((c) => c.id), 73 - }, 74 - { 75 - onError: () => { 76 - notifications.show({ 77 - message: 'Could not add card.', 78 - }); 79 - }, 80 - onSettled: () => { 81 - props.onClose(); 82 - }, 83 - }, 84 - ); 85 - return; 86 - } 87 - 88 - // otherwise, update existing card associations 89 - const updatedCardPayload: { 90 - cardId: string; 91 - note?: string; 92 - addToCollectionIds?: string[]; 93 - removeFromCollectionIds?: string[]; 94 - } = { cardId: cardStatus.data.cardId }; 95 - 96 - if (hasNoteChanged) updatedCardPayload.note = trimmedNote; 97 - if (hasAdded) 98 - updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id); 99 - if (hasRemoved) 100 - updatedCardPayload.removeFromCollectionIds = removedCollections.map( 101 - (c) => c.id, 102 - ); 103 - 104 - updateCardAssociations.mutate(updatedCardPayload, { 105 - onError: () => { 106 - notifications.show({ 107 - message: 'Could not update card.', 108 - }); 109 - }, 110 - onSettled: () => { 111 - props.onClose(); 112 - }, 113 - }); 114 - }; 115 - 116 - if (error) { 117 - return <CollectionSelectorError />; 118 - } 119 - 120 19 return ( 121 20 <Modal 122 21 opened={props.isOpen} 123 - onClose={() => { 124 - props.onClose(); 125 - setSelectedCollections(collectionsWithCard); 126 - }} 22 + onClose={props.onClose} 127 23 title={ 128 24 <Stack gap={0}> 129 25 <Text fw={600}>Add or update card</Text> 130 - <Text c={'gray'} fw={500}> 26 + <Text c="gray" fw={500}> 131 27 {props.isInYourLibrary 132 28 ? props.urlLibraryCount === 1 133 29 ? 'Saved by you' ··· 142 38 centered 143 39 onClick={(e) => e.stopPropagation()} 144 40 > 145 - <Stack justify="space-between"> 146 - <CardToBeAddedPreview 147 - cardContent={props.cardContent} 148 - note={isMyCard ? note : undefined} 149 - onUpdateNote={setNote} 150 - /> 151 - 41 + {props.isOpen && ( 152 42 <Suspense fallback={<CollectionSelectorSkeleton />}> 153 - <CollectionSelector 154 - isOpen={true} 155 - onClose={props.onClose} 156 - onCancel={() => { 157 - props.onClose(); 158 - setSelectedCollections(collectionsWithCard); 159 - }} 160 - onSave={handleUpdateCard} 161 - selectedCollections={selectedCollections} 162 - onSelectedCollectionsChange={setSelectedCollections} 163 - /> 43 + <AddCardToModalContent {...props} /> 164 44 </Suspense> 165 - </Stack> 45 + )} 166 46 </Modal> 167 47 ); 168 48 }
+143
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
··· 1 + 'use client'; 2 + 3 + import type { UrlCard } from '@/api-client'; 4 + import { Stack } from '@mantine/core'; 5 + import { notifications } from '@mantine/notifications'; 6 + import { useState } from 'react'; 7 + import CollectionSelectorError from '@/features/collections/components/collectionSelector/Error.CollectionSelector'; 8 + import CardToBeAddedPreview from '@/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview'; 9 + import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; 10 + import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 11 + import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 12 + import useUpdateCardAssociations from '@/features/cards/lib/mutations/useUpdateCardAssociations'; 13 + import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 14 + 15 + interface SelectableCollectionItem { 16 + id: string; 17 + name: string; 18 + cardCount: number; 19 + } 20 + 21 + interface Props { 22 + onClose: () => void; 23 + cardContent: UrlCard['cardContent']; 24 + urlLibraryCount: number; 25 + cardId: string; 26 + note?: string; 27 + isInYourLibrary: boolean; 28 + } 29 + 30 + export default function AddCardToModalContent(props: Props) { 31 + const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 32 + const isMyCard = props.cardId === cardStatus.data.card?.id; 33 + const [note, setNote] = useState(isMyCard ? props.note : ''); 34 + const { data, error } = useMyCollections(); 35 + 36 + const addCard = useAddCard(); 37 + const updateCardAssociations = useUpdateCardAssociations(); 38 + 39 + if (error) { 40 + return <CollectionSelectorError />; 41 + } 42 + 43 + const allCollections = 44 + data?.pages.flatMap((page) => page.collections ?? []) ?? []; 45 + 46 + const collectionsWithCard = allCollections.filter((c) => 47 + cardStatus.data.collections?.some((col) => col.id === c.id), 48 + ); 49 + 50 + const [selectedCollections, setSelectedCollections] = 51 + useState<SelectableCollectionItem[]>(collectionsWithCard); 52 + 53 + const handleUpdateCard = (e: React.FormEvent) => { 54 + e.preventDefault(); 55 + 56 + const trimmedNote = note?.trimEnd() === '' ? undefined : note; 57 + 58 + const addedCollections = selectedCollections.filter( 59 + (c) => !collectionsWithCard.some((original) => original.id === c.id), 60 + ); 61 + 62 + const removedCollections = collectionsWithCard.filter( 63 + (c) => !selectedCollections.some((selected) => selected.id === c.id), 64 + ); 65 + 66 + const hasNoteChanged = trimmedNote !== props.note; 67 + const hasAdded = addedCollections.length > 0; 68 + const hasRemoved = removedCollections.length > 0; 69 + 70 + // no change, close modal 71 + if (cardStatus.data.card && !hasNoteChanged && !hasAdded && !hasRemoved) { 72 + props.onClose(); 73 + return; 74 + } 75 + 76 + // card not yet in library, add it 77 + if (!cardStatus.data.card) { 78 + addCard.mutate( 79 + { 80 + url: props.cardContent.url, 81 + note: trimmedNote, 82 + collectionIds: selectedCollections.map((c) => c.id), 83 + }, 84 + { 85 + onError: () => { 86 + notifications.show({ message: 'Could not add card.' }); 87 + }, 88 + onSettled: () => { 89 + props.onClose(); 90 + }, 91 + }, 92 + ); 93 + return; 94 + } 95 + 96 + // card already in library, update associations instead 97 + const updatedCardPayload: { 98 + cardId: string; 99 + note?: string; 100 + addToCollectionIds?: string[]; 101 + removeFromCollectionIds?: string[]; 102 + } = { cardId: cardStatus.data.card.id }; 103 + 104 + if (hasNoteChanged) updatedCardPayload.note = trimmedNote; 105 + if (hasAdded) 106 + updatedCardPayload.addToCollectionIds = addedCollections.map((c) => c.id); 107 + if (hasRemoved) 108 + updatedCardPayload.removeFromCollectionIds = removedCollections.map( 109 + (c) => c.id, 110 + ); 111 + 112 + updateCardAssociations.mutate(updatedCardPayload, { 113 + onError: () => { 114 + notifications.show({ message: 'Could not update card.' }); 115 + }, 116 + onSettled: () => { 117 + props.onClose(); 118 + }, 119 + }); 120 + }; 121 + 122 + return ( 123 + <Stack justify="space-between"> 124 + <CardToBeAddedPreview 125 + cardContent={props.cardContent} 126 + note={isMyCard ? note : undefined} 127 + onUpdateNote={setNote} 128 + /> 129 + 130 + <CollectionSelector 131 + isOpen={true} 132 + onClose={props.onClose} 133 + onCancel={() => { 134 + props.onClose(); 135 + setSelectedCollections(collectionsWithCard); 136 + }} 137 + onSave={handleUpdateCard} 138 + selectedCollections={selectedCollections} 139 + onSelectedCollectionsChange={setSelectedCollections} 140 + /> 141 + </Stack> 142 + ); 143 + }
+19 -25
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 8 8 Group, 9 9 Anchor, 10 10 AspectRatio, 11 - Skeleton, 12 11 Tooltip, 13 12 } from '@mantine/core'; 14 13 import Link from 'next/link'; 15 14 import UrlCardActions from '../urlCardActions/UrlCardActions'; 16 - import { MouseEvent, Suspense } from 'react'; 15 + import { MouseEvent } from 'react'; 17 16 import { useRouter } from 'next/navigation'; 18 17 import styles from './UrlCard.module.css'; 19 18 ··· 68 67 </Anchor> 69 68 </Tooltip> 70 69 {props.cardContent.title && ( 71 - <Text fw={500} lineClamp={2}> 70 + <Text 71 + fw={500} 72 + lineClamp={2} 73 + style={{ 74 + wordBreak: 'break-word', 75 + }} 76 + > 72 77 {props.cardContent.title} 73 78 </Text> 74 79 )} ··· 91 96 )} 92 97 </Group> 93 98 94 - <Suspense 95 - fallback={ 96 - <Group justify="space-between"> 97 - <Group gap={'xs'}> 98 - <Skeleton w={22} h={22} /> 99 - </Group> 100 - <Skeleton w={22} h={22} /> 101 - </Group> 102 - } 103 - > 104 - <UrlCardActions 105 - cardAuthor={props.cardAuthor} 106 - cardContent={props.cardContent} 107 - cardCount={props.urlLibraryCount} 108 - id={props.id} 109 - authorHandle={props.authorHandle} 110 - note={props.note} 111 - currentCollection={props.currentCollection} 112 - urlLibraryCount={props.urlLibraryCount} 113 - urlIsInLibrary={props.urlIsInLibrary!!} 114 - /> 115 - </Suspense> 99 + <UrlCardActions 100 + cardAuthor={props.cardAuthor} 101 + cardContent={props.cardContent} 102 + cardCount={props.urlLibraryCount} 103 + id={props.id} 104 + authorHandle={props.authorHandle} 105 + note={props.note} 106 + currentCollection={props.currentCollection} 107 + urlLibraryCount={props.urlLibraryCount} 108 + urlIsInLibrary={props.urlIsInLibrary!!} 109 + /> 116 110 </Stack> 117 111 </Card> 118 112 );
+1 -7
src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx
··· 1 1 'use client'; 2 2 3 - import { Center, Container, Grid, Loader, Stack } from '@mantine/core'; 3 + import { Container, Grid } from '@mantine/core'; 4 4 import useCards from '../../lib/queries/useCards'; 5 5 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 6 6 import CardsContainerError from './Error.CardsContainer'; ··· 52 52 isInitialLoading={isPending} 53 53 isLoading={isFetchingNextPage} 54 54 loadMore={fetchNextPage} 55 - manualLoadButton={false} 56 - loader={ 57 - <Center> 58 - <Loader /> 59 - </Center> 60 - } 61 55 > 62 56 <Grid gutter="md"> 63 57 {allCards.map((card) => (
+7
src/webapp/features/cards/lib/dal.ts
··· 7 7 8 8 return response; 9 9 }); 10 + 11 + export const getCardFromMyLibrary = cache(async (url: string) => { 12 + const client = createSembleClient(); 13 + const response = await client.getUrlStatusForMyLibrary({ url: url }); 14 + 15 + return response; 16 + });
+2 -6
src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseQuery } from '@tanstack/react-query'; 2 + import { getCardFromMyLibrary } from '../dal'; 3 3 4 4 interface Props { 5 5 url: string; 6 6 } 7 7 8 8 export default function useGetCardFromMyLibrary(props: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 9 const cardStatus = useSuspenseQuery({ 14 10 queryKey: ['card from my library', props.url], 15 - queryFn: () => apiClient.getUrlStatusForMyLibrary({ url: props.url }), 11 + queryFn: () => getCardFromMyLibrary(props.url), 16 12 }); 17 13 18 14 return cardStatus;
+1
src/webapp/features/collections/components/deleteCollectionModal/DeleteCollectionModal.tsx
··· 51 51 size="md" 52 52 onClick={handleDeleteCollection} 53 53 loading={deleteCollection.isPending} 54 + data-autofocus 54 55 > 55 56 Delete 56 57 </Button>
+10
src/webapp/features/collections/lib/dal.ts
··· 18 18 return response; 19 19 }, 20 20 ); 21 + 22 + export const getMyCollections = cache(async (params?: PageParams) => { 23 + const client = createSembleClient(); 24 + const response = await client.getMyCollections({ 25 + page: params?.page, 26 + limit: params?.limit, 27 + }); 28 + 29 + return response; 30 + });
+2 -7
src/webapp/features/collections/lib/queries/useMyCollections.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getMyCollections } from '../dal'; 3 3 4 4 interface Props { 5 5 limit?: number; 6 6 } 7 7 8 8 export default function useMyCollections(props?: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 9 const limit = props?.limit ?? 15; 14 10 15 11 return useSuspenseInfiniteQuery({ 16 12 queryKey: ['collections', limit], 17 13 initialPageParam: 1, 18 - queryFn: ({ pageParam }) => 19 - apiClient.getMyCollections({ limit, page: pageParam }), 14 + queryFn: ({ pageParam }) => getMyCollections({ limit, page: pageParam }), 20 15 getNextPageParam: (lastPage) => { 21 16 return lastPage.pagination.hasMore 22 17 ? lastPage.pagination.currentPage + 1
+1 -7
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
··· 2 2 3 3 import useMyFeed from '@/features/feeds/lib/queries/useMyFeed'; 4 4 import FeedItem from '@/features/feeds/components/feedItem/FeedItem'; 5 - import { Stack, Title, Text, Center, Container, Loader } from '@mantine/core'; 5 + import { Stack, Title, Text, Center, Container } from '@mantine/core'; 6 6 import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; 7 7 import MyFeedContainerError from './Error.MyFeedContainer'; 8 8 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; ··· 46 46 isInitialLoading={isPending} 47 47 isLoading={isFetchingNextPage} 48 48 loadMore={fetchNextPage} 49 - manualLoadButton={false} 50 - loader={ 51 - <Center> 52 - <Loader /> 53 - </Center> 54 - } 55 49 > 56 50 <Stack gap={'xl'} mx={'auto'} maw={600}> 57 51 <Stack gap={60}>
+3 -9
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 13 13 import { truncateText } from '@/lib/utils/text'; 14 14 import MinimalProfileHeaderContainer from '../../containers/minimalProfileHeaderContainer/MinimalProfileHeaderContainer'; 15 15 import { FaBluesky } from 'react-icons/fa6'; 16 - import { ApiClient } from '@/api-client/ApiClient'; 16 + import { getProfile } from '../../lib/dal'; 17 17 18 18 interface Props { 19 19 handle: string; 20 20 } 21 21 22 22 export default async function ProfileHeader(props: Props) { 23 - const apiClient = new ApiClient( 24 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 25 - ); 26 - 27 - const profile = await apiClient.getProfile({ 28 - identifier: props.handle, 29 - }); 23 + const profile = await getProfile(props.handle); 30 24 31 25 return ( 32 26 <Container bg={'white'} p={'xs'} size={'xl'}> ··· 42 36 <Avatar 43 37 src={profile.avatarUrl} 44 38 alt={`${profile.name}'s avatar`} 45 - size={'clamp(100px, 22vw, 140px)'} 39 + size={'clamp(90px, 22vw, 140px)'} 46 40 radius={'lg'} 47 41 /> 48 42 </GridCol>
+16 -12
src/webapp/features/profile/components/profileTabs/ProfileTabs.tsx
··· 1 1 'use client'; 2 2 3 - import { Tabs } from '@mantine/core'; 3 + import { Group, ScrollAreaAutosize, Tabs } from '@mantine/core'; 4 4 import TabItem from './TabItem'; 5 5 import { usePathname } from 'next/navigation'; 6 6 ··· 16 16 17 17 return ( 18 18 <Tabs value={currentTab}> 19 - <Tabs.List> 20 - <TabItem value="profile" href={basePath}> 21 - Profile 22 - </TabItem> 23 - <TabItem value="cards" href={`${basePath}/cards`}> 24 - Cards 25 - </TabItem> 26 - <TabItem value="collections" href={`${basePath}/collections`}> 27 - Collections 28 - </TabItem> 29 - </Tabs.List> 19 + <ScrollAreaAutosize type="scroll"> 20 + <Tabs.List> 21 + <Group wrap="nowrap"> 22 + <TabItem value="profile" href={basePath}> 23 + Profile 24 + </TabItem> 25 + <TabItem value="cards" href={`${basePath}/cards`}> 26 + Cards 27 + </TabItem> 28 + <TabItem value="collections" href={`${basePath}/collections`}> 29 + Collections 30 + </TabItem> 31 + </Group> 32 + </Tabs.List> 33 + </ScrollAreaAutosize> 30 34 </Tabs> 31 35 ); 32 36 }
+7
src/webapp/features/profile/lib/dal.ts
··· 9 9 10 10 return response; 11 11 }); 12 + 13 + export const getMyProfile = cache(async () => { 14 + const client = createSembleClient(); 15 + const response = await client.getMyProfile(); 16 + 17 + return response; 18 + });
+4 -10
src/webapp/features/profile/lib/queries/useMyProfile.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 - import { useSuspenseQuery } from '@tanstack/react-query'; 1 + import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; 2 + import { getMyProfile } from '../dal'; 3 3 4 4 export default function useMyProfile() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 - const myProfile = useSuspenseQuery({ 5 + return useSuspenseQuery({ 10 6 queryKey: ['my profile'], 11 - queryFn: () => apiClient.getMyProfile(), 7 + queryFn: () => getMyProfile(), 12 8 }); 13 - 14 - return myProfile; 15 9 }
+1 -7
src/webapp/features/semble/containers/sembleCollectionsContainer/SembleCollectionsContainer.tsx
··· 2 2 3 3 import useSembleCollections from '@/features/collections/lib/queries/useSembleCollectionts'; 4 4 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 5 - import { Center, Loader, SimpleGrid } from '@mantine/core'; 5 + import { SimpleGrid } from '@mantine/core'; 6 6 import SembleCollectionsError from './Error.SembleCollectionsContainer'; 7 7 import CollectionCard from '@/features/collections/components/collectionCard/CollectionCard'; 8 8 import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; ··· 40 40 isInitialLoading={isPending} 41 41 isLoading={isFetchingNextPage} 42 42 loadMore={fetchNextPage} 43 - manualLoadButton={false} 44 - loader={ 45 - <Center> 46 - <Loader /> 47 - </Center> 48 - } 49 43 > 50 44 <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 51 45 {allCollections.map((col) => (
+1 -7
src/webapp/features/semble/containers/sembleLibrariesContainer/SembleLibrariesContainer.tsx
··· 2 2 3 3 import useSembleLibraries from '../../lib/queries/useSembleLibraries'; 4 4 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 5 - import { Center, Divider, Grid, Loader } from '@mantine/core'; 5 + import { Grid } from '@mantine/core'; 6 6 import SembleLibrariesContainerError from './Error.SembleLibrariesContainer'; 7 7 import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; 8 8 import { LuLibrary } from 'react-icons/lu'; ··· 45 45 isInitialLoading={isPending} 46 46 isLoading={isFetchingNextPage} 47 47 loadMore={fetchNextPage} 48 - manualLoadButton={false} 49 - loader={ 50 - <Center> 51 - <Loader /> 52 - </Center> 53 - } 54 48 > 55 49 <Grid gutter="md"> 56 50 {allLibraries.map((item, i) => (
+1 -7
src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx
··· 2 2 3 3 import useSembleNotes from '@/features/notes/lib/queries/useSembleNotes'; 4 4 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 5 - import { Center, Grid, Loader } from '@mantine/core'; 5 + import { Grid } from '@mantine/core'; 6 6 import SembleNotesContainerError from './Error.SembleNotesContainer'; 7 7 import NoteCard from '@/features/notes/components/noteCard/NoteCard'; 8 8 import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; ··· 42 42 isInitialLoading={isPending} 43 43 isLoading={isFetchingNextPage} 44 44 loadMore={fetchNextPage} 45 - manualLoadButton={false} 46 - loader={ 47 - <Center> 48 - <Loader /> 49 - </Center> 50 - } 51 45 > 52 46 <Grid gutter="md"> 53 47 {allNotes.map((note) => (
+41 -107
src/webapp/hooks/useAuth.tsx
··· 1 1 'use client'; 2 2 3 - import { 4 - useState, 5 - useEffect, 6 - createContext, 7 - useContext, 8 - ReactNode, 9 - useCallback, 10 - } from 'react'; 3 + import { createContext, useContext, ReactNode, useEffect } from 'react'; 4 + import { useQuery, useQueryClient } from '@tanstack/react-query'; 11 5 import { useRouter } from 'next/navigation'; 12 - import { ClientCookieAuthService } from '@/services/auth'; 13 - import { ApiClient } from '@/api-client/ApiClient'; 14 6 import type { GetProfileResponse } from '@/api-client/ApiClient'; 7 + import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 15 8 16 9 type UserProfile = GetProfileResponse; 17 10 18 - interface AuthState { 11 + interface AuthContextType { 12 + user: UserProfile | null; 19 13 isAuthenticated: boolean; 20 14 isLoading: boolean; 21 - user: UserProfile | null; 22 - } 23 - 24 - interface AuthContextType extends AuthState { 25 - login: (handle: string) => Promise<{ authUrl: string }>; 15 + refreshAuth: () => Promise<void>; 26 16 logout: () => Promise<void>; 27 - refreshAuth: () => Promise<boolean>; 28 17 } 29 18 30 19 const AuthContext = createContext<AuthContextType | undefined>(undefined); 31 20 32 21 export const AuthProvider = ({ children }: { children: ReactNode }) => { 33 - const [authState, setAuthState] = useState<AuthState>({ 34 - isAuthenticated: false, 35 - isLoading: true, 36 - user: null, 37 - }); 38 - 39 22 const router = useRouter(); 23 + const queryClient = useQueryClient(); 40 24 41 - // Refresh authentication (fetch user profile with automatic token refresh) 42 - const refreshAuth = useCallback(async (): Promise<boolean> => { 43 - try { 44 - // Call /api/auth/me which handles token refresh + profile fetch 45 - // HttpOnly cookies sent automatically with credentials: 'include' 25 + const refreshAuth = async () => { 26 + await query.refetch(); 27 + }; 28 + 29 + const logout = async () => { 30 + await ClientCookieAuthService.clearTokens(); 31 + queryClient.removeQueries({ queryKey: ['authenticated user'] }); 32 + router.push('/login'); 33 + }; 34 + 35 + const query = useQuery<UserProfile | null>({ 36 + queryKey: ['authenticated user'], 37 + queryFn: async () => { 46 38 const response = await fetch('/api/auth/me', { 47 39 method: 'GET', 48 - credentials: 'include', 40 + credentials: 'include', // HttpOnly cookies sent automatically 49 41 }); 50 42 43 + // unauthenticated 51 44 if (!response.ok) { 52 - // Clear tokens when auth fails 53 - await ClientCookieAuthService.clearTokens(); 54 - setAuthState({ 55 - isAuthenticated: false, 56 - user: null, 57 - isLoading: false, 58 - }); 59 - return false; 45 + throw new Error('Not authenticated'); 60 46 } 61 47 62 - const { user } = await response.json(); 63 - 64 - setAuthState({ 65 - isAuthenticated: true, 66 - user, 67 - isLoading: false, 68 - }); 69 - 70 - return true; 71 - } catch (error) { 72 - console.error('Auth refresh failed:', error); 73 - // Clear tokens on error too 74 - await ClientCookieAuthService.clearTokens(); 75 - setAuthState({ 76 - isAuthenticated: false, 77 - user: null, 78 - isLoading: false, 79 - }); 80 - return false; 81 - } 82 - }, []); 48 + const data = await response.json(); 49 + return data.user as UserProfile; 50 + }, 51 + staleTime: 5 * 60 * 1000, // cache for 5 minutes 52 + refetchOnWindowFocus: false, 53 + retry: false, 54 + }); 83 55 84 - // Initialize auth on mount 85 56 useEffect(() => { 86 - refreshAuth(); 87 - }, [refreshAuth]); 88 - 89 - // Periodic auth check (every 5 minutes) 90 - useEffect(() => { 91 - if (!authState.isAuthenticated) return; 92 - 93 - const interval = setInterval( 94 - async () => { 95 - await refreshAuth(); 96 - }, 97 - 5 * 60 * 1000, 98 - ); // Check every 5 minutes 99 - 100 - return () => clearInterval(interval); 101 - }, [authState.isAuthenticated, refreshAuth]); 102 - 103 - const login = useCallback(async (handle: string) => { 104 - const apiClient = new ApiClient( 105 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 106 - ); 107 - return await apiClient.initiateOAuthSignIn({ handle }); 108 - }, []); 57 + if (query.isError) logout(); 58 + }, [query.isError, logout]); 109 59 110 - const logout = useCallback(async () => { 111 - try { 112 - await ClientCookieAuthService.clearTokens(); 113 - } catch (error) { 114 - console.error('Logout error:', error); 115 - } finally { 116 - setAuthState({ 117 - isAuthenticated: false, 118 - isLoading: false, 119 - user: null, 120 - }); 121 - router.push('/login'); 122 - } 123 - }, [router]); 60 + const contextValue: AuthContextType = { 61 + user: query.data ?? null, 62 + isAuthenticated: !!query.data, 63 + isLoading: query.isLoading, 64 + refreshAuth, 65 + logout, 66 + }; 124 67 125 68 return ( 126 - <AuthContext.Provider 127 - value={{ 128 - ...authState, 129 - login, 130 - logout, 131 - refreshAuth, 132 - }} 133 - > 134 - {children} 135 - </AuthContext.Provider> 69 + <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider> 136 70 ); 137 71 }; 138 72 139 - export const useAuth = () => { 73 + export const useAuth = (): AuthContextType => { 140 74 const context = useContext(AuthContext); 141 75 if (!context) { 142 76 throw new Error('useAuth must be used within an AuthProvider');
+1 -1
src/webapp/hooks/useUrlMetadata.ts
··· 46 46 }); 47 47 48 48 // If there's an existing card, fetch its collections 49 - if (existingCard.cardId) { 49 + if (existingCard.card) { 50 50 try { 51 51 setExistingCardCollections(existingCard.collections); 52 52 } catch (cardErr: any) {
+90
src/webapp/lib/auth/dal.ts
··· 1 + import 'server-only'; 2 + 3 + import { cache } from 'react'; 4 + import { cookies } from 'next/headers'; 5 + import { redirect } from 'next/navigation'; 6 + import { isTokenExpiringSoon } from './token'; 7 + 8 + export const verifySession = cache(async () => { 9 + const cookieStore = await cookies(); 10 + const accessToken = cookieStore.get('accessToken')?.value; 11 + const refreshToken = cookieStore.get('refreshToken')?.value; 12 + 13 + // no session tokens — redirect to login 14 + if (!accessToken && !refreshToken) { 15 + redirect('/login'); 16 + } 17 + 18 + const backendUrl = 19 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 20 + 21 + // token expired or about to expire 22 + if ((!accessToken || isTokenExpiringSoon(accessToken)) && refreshToken) { 23 + const refreshResponse = await fetch( 24 + `${backendUrl}/api/users/oauth/refresh`, 25 + { 26 + method: 'POST', 27 + headers: { 'Content-Type': 'application/json' }, 28 + body: JSON.stringify({ refreshToken }), 29 + cache: 'no-store', 30 + }, 31 + ); 32 + 33 + if (!refreshResponse.ok) { 34 + // clear invalid tokens and redirect to login 35 + cookieStore.delete('accessToken'); 36 + cookieStore.delete('refreshToken'); 37 + redirect('/login'); 38 + } 39 + 40 + const newTokens = await refreshResponse.json(); 41 + const newAccessToken = newTokens.accessToken; 42 + 43 + // update cookie 44 + cookieStore.set('accessToken', newAccessToken, { 45 + httpOnly: true, 46 + secure: true, 47 + sameSite: 'lax', 48 + path: '/', 49 + }); 50 + 51 + return { isAuthenticated: true }; 52 + } 53 + 54 + // if access token is valid 55 + return { isAuthenticated: true }; 56 + }); 57 + 58 + export const getUser = cache(async () => { 59 + const cookieStore = await cookies(); 60 + const accessToken = cookieStore.get('accessToken')?.value; 61 + const refreshToken = cookieStore.get('refreshToken')?.value; 62 + 63 + if (!accessToken && !refreshToken) redirect('/login'); 64 + 65 + const backendUrl = 66 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 67 + 68 + // Forward cookies manually 69 + const cookieHeader = [ 70 + accessToken ? `accessToken=${accessToken}` : null, 71 + refreshToken ? `refreshToken=${refreshToken}` : null, 72 + ] 73 + .filter(Boolean) 74 + .join('; '); 75 + 76 + const res = await fetch(`${backendUrl}/api/users/me`, { 77 + headers: { 78 + 'Content-Type': 'application/json', 79 + Cookie: cookieHeader, // forward the cookies 80 + }, 81 + cache: 'no-store', 82 + }); 83 + 84 + if (!res.ok) { 85 + redirect('/login'); 86 + } 87 + 88 + const user = await res.json(); 89 + return user; 90 + });
+17
src/webapp/lib/auth/token.ts
··· 1 + export const isTokenExpiringSoon = ( 2 + token: string | null | undefined, 3 + bufferMinutes: number = 5, 4 + ): boolean => { 5 + if (!token) return true; 6 + 7 + try { 8 + const payload = JSON.parse( 9 + Buffer.from(token.split('.')[1], 'base64').toString(), 10 + ); 11 + const expiry = payload.exp * 1000; 12 + const bufferTime = bufferMinutes * 60 * 1000; 13 + return Date.now() >= expiry - bufferTime; 14 + } catch { 15 + return true; 16 + } 17 + };