This repository has no description
0

Configure Feed

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

Merge pull request #491 from cosmik-network/chore/margin-open-collection-merge

Chore/margin open collection merge

+6393 -96
+215
.agent/logs/20260121_feature_implementation_guide.md
··· 1 + # Feature Implementation Guide - Vertical Slice Pattern 2 + 3 + This guide outlines the standard pattern for implementing a complete feature in the Semble codebase, following a vertical slice architecture from API client to database. 4 + 5 + ## Overview 6 + 7 + Each feature follows a consistent layered architecture: 8 + 9 + - **API Client** - TypeScript types and client methods 10 + - **HTTP Layer** - Controllers and routes 11 + - **Application Layer** - Use cases (business logic) 12 + - **Domain Layer** - Entities, value objects, and domain services 13 + - **Infrastructure Layer** - Repositories, external services, and persistence 14 + 15 + ## Implementation Steps 16 + 17 + ### 1. Define Types (`src/types/src/api/`) 18 + 19 + Start by defining the request/response types that will be used across the API: 20 + 21 + **Request Types** (`requests.ts`): 22 + 23 + ```typescript 24 + export interface MyFeatureRequest { 25 + param1: string; 26 + param2?: number; 27 + } 28 + ``` 29 + 30 + **Response Types** (`responses.ts`): 31 + 32 + ```typescript 33 + export interface MyFeatureResponse { 34 + id: string; 35 + result: string; 36 + } 37 + ``` 38 + 39 + ### 2. Create Use Case (`src/modules/{module}/application/useCases/`) 40 + 41 + Implement the business logic in a use case class: 42 + 43 + ```typescript 44 + export class MyFeatureUseCase extends BaseUseCase< 45 + MyFeatureDTO, 46 + Result<MyFeatureResponseDTO, ValidationError | AppError.UnexpectedError> 47 + > { 48 + constructor( 49 + private repository: IMyRepository, 50 + private domainService: MyDomainService, 51 + eventPublisher: IEventPublisher, 52 + ) { 53 + super(eventPublisher); 54 + } 55 + 56 + async execute(request: MyFeatureDTO): Promise<Result<...>> { 57 + // Business logic implementation 58 + } 59 + } 60 + ``` 61 + 62 + ### 3. Create Controller (`src/modules/{module}/infrastructure/http/controllers/`) 63 + 64 + Handle HTTP requests and delegate to use cases: 65 + 66 + ```typescript 67 + export class MyFeatureController extends Controller { 68 + constructor(private useCase: MyFeatureUseCase) { 69 + super(); 70 + } 71 + 72 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 73 + const result = await this.useCase.execute(req.body); 74 + 75 + if (result.isErr()) { 76 + return this.fail(res, result.error); 77 + } 78 + 79 + return this.ok(res, result.value); 80 + } 81 + } 82 + ``` 83 + 84 + ### 4. Add Route (`src/modules/{module}/infrastructure/http/routes/`) 85 + 86 + Define the HTTP endpoint: 87 + 88 + ```typescript 89 + router.post('/my-feature', authMiddleware.ensureAuthenticated(), (req, res) => 90 + myFeatureController.execute(req, res), 91 + ); 92 + ``` 93 + 94 + ### 5. Wire Dependencies (`src/shared/infrastructure/http/factories/`) 95 + 96 + **UseCaseFactory.ts** - Add use case instantiation: 97 + 98 + ```typescript 99 + myFeatureUseCase: new MyFeatureUseCase( 100 + repositories.myRepository, 101 + services.myDomainService, 102 + services.eventPublisher, 103 + ), 104 + ``` 105 + 106 + **ControllerFactory.ts** - Add controller instantiation: 107 + 108 + ```typescript 109 + myFeatureController: new MyFeatureController( 110 + useCases.myFeatureUseCase, 111 + ), 112 + ``` 113 + 114 + ### 6. Add API Client Method (`src/webapp/api-client/`) 115 + 116 + **Client Class** (`clients/MyClient.ts`): 117 + 118 + ```typescript 119 + export class MyClient extends BaseClient { 120 + async myFeature(request: MyFeatureRequest): Promise<MyFeatureResponse> { 121 + return this.request<MyFeatureResponse>('POST', '/api/my-feature', request); 122 + } 123 + } 124 + ``` 125 + 126 + **Main API Client** (`ApiClient.ts`): 127 + 128 + ```typescript 129 + async myFeature(request: MyFeatureRequest): Promise<MyFeatureResponse> { 130 + return this.myClient.myFeature(request); 131 + } 132 + ``` 133 + 134 + ## Key Patterns 135 + 136 + ### Repository Pattern 137 + 138 + - Interface in domain layer (`src/modules/{module}/domain/`) 139 + - Implementation in infrastructure layer (`src/modules/{module}/infrastructure/repositories/`) 140 + - Both real (Drizzle) and in-memory test implementations 141 + 142 + ### Domain Services 143 + 144 + - Business logic that doesn't belong to a single entity 145 + - Located in `src/modules/{module}/domain/services/` 146 + - Injected into use cases 147 + 148 + ### Value Objects 149 + 150 + - Immutable objects representing domain concepts 151 + - Located in `src/modules/{module}/domain/value-objects/` 152 + - Include validation logic 153 + 154 + ### Error Handling 155 + 156 + - Use `Result<T, E>` pattern for error handling 157 + - Custom error types extend `UseCaseError` 158 + - Controllers handle different error types appropriately 159 + 160 + ### Authentication 161 + 162 + - Use `AuthMiddleware.ensureAuthenticated()` for protected routes 163 + - Use `AuthMiddleware.optionalAuth()` for routes that work with/without auth 164 + - Access user DID via `req.did` in controllers 165 + 166 + ### Event Publishing 167 + 168 + - Use cases extend `BaseUseCase` for event publishing 169 + - Call `this.publishEventsForAggregate(entity)` after operations 170 + - Events are handled by separate worker processes 171 + 172 + ## File Structure Example 173 + 174 + For a feature called "MyFeature" in the "cards" module: 175 + 176 + ``` 177 + src/ 178 + ├── types/src/api/ 179 + │ ├── requests.ts (add MyFeatureRequest) 180 + │ └── responses.ts (add MyFeatureResponse) 181 + ├── modules/cards/ 182 + │ ├── domain/ 183 + │ │ ├── IMyRepository.ts 184 + │ │ ├── services/MyDomainService.ts 185 + │ │ └── value-objects/MyValueObject.ts 186 + │ ├── application/useCases/ 187 + │ │ └── commands/MyFeatureUseCase.ts 188 + │ └── infrastructure/ 189 + │ ├── repositories/DrizzleMyRepository.ts 190 + │ └── http/ 191 + │ ├── controllers/MyFeatureController.ts 192 + │ └── routes/ (update existing route file) 193 + ├── shared/infrastructure/http/factories/ 194 + │ ├── UseCaseFactory.ts (add use case) 195 + │ ├── ControllerFactory.ts (add controller) 196 + │ └── RepositoryFactory.ts (add repository) 197 + └── webapp/api-client/ 198 + ├── clients/MyClient.ts 199 + └── ApiClient.ts (add method) 200 + ``` 201 + 202 + ## Testing Strategy 203 + 204 + - **Unit Tests**: Test use cases and domain services in isolation 205 + - **Integration Tests**: Test controllers with real dependencies 206 + - **In-Memory Implementations**: Use for testing and development 207 + - **Mock Services**: Use `Fake*` implementations for external dependencies 208 + 209 + ## Configuration 210 + 211 + - Use `EnvironmentConfigService` for environment-specific settings 212 + - Support both mock and real implementations via config flags 213 + - Factory pattern allows switching implementations based on environment 214 + 215 + This pattern ensures consistency, testability, and maintainability across all features in the codebase.
+26
.agent/logs/20260121_open_collections_spec.md
··· 1 + - creating a collection 2 + - in create collection drawer, include drop down for selecting the access type 3 + - updating collection access type 4 + - creators can change the access type afterwards 5 + - 6 + - viewing a collection 7 + - access type should be visible on the collection page and potentially even from the collection “card” view - means the `Collection` type in responses.ts needs to include the accessType 8 + - cards should indicate the author who added it 9 + - could be a small footnote “added by user A” or something else - UI needs to know if its an open collection 10 + - maybe this should only show if the card author differs from the collection author, that way its the same logic for open and closed and handled the case where an open becomes closed 11 + - adding to a collection 12 + - if creator - will show up as normal 13 + - if contributor, will show up in a separate “open collections” tab 14 + - consider showing recently added to open collections at the top of the list here 15 + - not sure if the feed activity item link to collection needs to look different or not 16 + - removing from a collection 17 + - creator can remove any of the cards in the collection 18 + - if not added by them / not their card, then will need to be handled slightly differently (directly added to the collection record itself?) 19 + - contributor can only remove the cards they’ve added (straightforward, delete the collection link as normal) 20 + - notifications 21 + - creator is notified whenever someone adds to their collection 22 + - user A added to your collection USER_ADDED_TO_YOUR_COLLECTION 23 + - Semble page 24 + - same as current collections tab, perhaps with some visual indication of the changes 25 + - api client 26 + - collection type should include access type
+331
.agent/logs/20260122_open_collection_removal_logic.md
··· 1 + # Open Collection Removal Logic Implementation 2 + 3 + **Date:** January 22, 2026 4 + 5 + ## Overview 6 + 7 + Implemented card removal logic for OPEN collections in the ATProto-based application, including the creation of a new `collectionLinkRemoval` lexicon record type to handle cases where collection owners need to remove cards added by other users. 8 + 9 + ## Problem Statement 10 + 11 + In ATProto, users can only modify records in their own repositories. When a collection owner wants to remove a card that was added by another user from an OPEN collection, they cannot delete the `collectionLink` record (which exists in the other user's repository). A solution was needed to mark these cards as removed without requiring direct deletion of another user's records. 12 + 13 + ## Solution: CollectionLinkRemoval Record 14 + 15 + Implemented Option 2 from the design phase: a separate `collectionLinkRemoval` record type that collection owners publish in their own repository to indicate a card has been removed. 16 + 17 + ### Lexicon Schema 18 + 19 + Created `src/modules/atproto/infrastructure/lexicons/collectionLinkRemoval.json`: 20 + 21 + ```json 22 + { 23 + "lexicon": 1, 24 + "id": "network.cosmik.collectionLinkRemoval", 25 + "description": "A record indicating that a card was removed from a collection by the collection owner.", 26 + "defs": { 27 + "main": { 28 + "type": "record", 29 + "description": "A record representing the removal of a collection link by a collection owner when they cannot delete the original link (which exists in another user's repository). The creator of this record (determined from the AT-URI) is the user who performed the removal.", 30 + "key": "tid", 31 + "record": { 32 + "type": "object", 33 + "required": ["collection", "removedLink", "removedAt"], 34 + "properties": { 35 + "collection": { 36 + "type": "ref", 37 + "description": "Strong reference to the collection record.", 38 + "ref": "com.atproto.repo.strongRef" 39 + }, 40 + "removedLink": { 41 + "type": "ref", 42 + "description": "Strong reference to the collectionLink record that is being removed.", 43 + "ref": "com.atproto.repo.strongRef" 44 + }, 45 + "removedAt": { 46 + "type": "string", 47 + "format": "datetime", 48 + "description": "Timestamp when the link was removed from the collection." 49 + } 50 + } 51 + } 52 + } 53 + } 54 + } 55 + ``` 56 + 57 + **Design Notes:** 58 + 59 + - No `removedBy` field - user identity is derived from the AT-URI of the removal record 60 + - No `reason` field - kept the schema minimal and focused 61 + - Uses StrongRef to ensure content-addressable references (URI + CID) 62 + 63 + ## Business Rules for OPEN Collections 64 + 65 + ### Removal Permissions 66 + 67 + **For OPEN Collections:** 68 + 69 + - ✅ Collection author can remove any card (including cards added by others) 70 + - ✅ Users can only remove cards they themselves added 71 + - ❌ Non-authors cannot remove cards added by others 72 + 73 + ### ATProto Publishing Logic 74 + 75 + When removing a card from an OPEN collection: 76 + 77 + 1. **User removing their own card:** 78 + - Unpublishes the `collectionLink` record (deletes from their repository) 79 + - No removal record is created 80 + 81 + 2. **Collection author removing someone else's card:** 82 + - Publishes a `collectionLinkRemoval` record in their own repository 83 + - The original `collectionLink` remains in the other user's repository (cannot be deleted) 84 + 85 + ## Implementation Details 86 + 87 + ### 1. Domain Layer 88 + 89 + **File:** `src/modules/cards/domain/Collection.ts` 90 + 91 + Updated the `removeCard()` method (lines 260-309) to enforce permissions based on collection type: 92 + 93 + ```typescript 94 + public removeCard( 95 + cardId: CardId, 96 + userId: CuratorId, 97 + ): Result<void, CollectionAccessError> { 98 + // Find the card link to check who added it 99 + const cardLink = this.props.cardLinks.find((link) => 100 + link.cardId.equals(cardId), 101 + ); 102 + 103 + // If card is not in collection, removal is a no-op (succeeds idempotently) 104 + if (!cardLink) { 105 + return ok(undefined); 106 + } 107 + 108 + // Check removal permissions based on collection type and user role 109 + const isAuthor = this.props.authorId.equals(userId); 110 + const isUserRemovingOwnCard = cardLink.addedBy.equals(userId); 111 + 112 + if (this.isOpen) { 113 + // For OPEN collections: 114 + // - Author can remove any card 115 + // - Users can only remove cards they added themselves 116 + if (!isAuthor && !isUserRemovingOwnCard) { 117 + return err( 118 + new CollectionAccessError( 119 + 'User does not have permission to remove cards from this collection', 120 + ), 121 + ); 122 + } 123 + } else { 124 + // For CLOSED collections: 125 + // Use the standard canAddCard check (author + collaborators) 126 + // Note: CLOSED collection removal scenarios with collaborators are deferred 127 + if (!this.canAddCard(userId)) { 128 + return err( 129 + new CollectionAccessError( 130 + 'User does not have permission to remove cards from this collection', 131 + ), 132 + ); 133 + } 134 + } 135 + 136 + this.props.cardLinks = this.props.cardLinks.filter( 137 + (link) => !link.cardId.equals(cardId), 138 + ); 139 + this.props.cardCount = this.props.cardLinks.length; 140 + this.props.updatedAt = new Date(); 141 + 142 + return ok(undefined); 143 + } 144 + ``` 145 + 146 + ### 2. Service Layer 147 + 148 + **File:** `src/modules/cards/domain/services/CardCollectionService.ts` 149 + 150 + The `removeCardFromCollection` method (lines 213-279) handles the publishing logic: 151 + 152 + ```typescript 153 + // Check permissions FIRST before attempting any publishing operations 154 + const canRemoveResult = collection.removeCard(card.cardId, curatorId); 155 + if (canRemoveResult.isErr()) { 156 + return err(new CardCollectionValidationError(...)); 157 + } 158 + // Re-add the card since we only wanted to check permissions 159 + collection.addCard(card.cardId, cardLink.addedBy, cardLink.viaCardId); 160 + 161 + // Handle unpublishing/removal based on options 162 + if (!options?.skipPublishing && cardLink.publishedRecordId) { 163 + const isUserRemovingOwnCard = cardLink.addedBy.equals(curatorId); 164 + const isCollectionAuthor = collection.authorId.equals(curatorId); 165 + 166 + if (isUserRemovingOwnCard) { 167 + // Unpublish the CollectionLink (delete from their repo) 168 + await collectionPublisher.unpublishCardAddedToCollection(...); 169 + } else if (isCollectionAuthor) { 170 + // Publish a CollectionLinkRemoval record (only author can do this) 171 + await collectionPublisher.publishCollectionLinkRemoval(...); 172 + } else { 173 + // Should never happen due to permission check above 174 + return err(new CardCollectionValidationError( 175 + 'User does not have permission to remove this card from the collection' 176 + )); 177 + } 178 + } 179 + ``` 180 + 181 + **Key Design Decision:** Permission check happens BEFORE any ATProto publishing operations to prevent unauthorized publishes. 182 + 183 + ### 3. Infrastructure Layer 184 + 185 + **File:** `src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts` 186 + 187 + Added `publishCollectionLinkRemoval` method (lines 280-350): 188 + 189 + ```typescript 190 + async publishCollectionLinkRemoval( 191 + card: Card, 192 + collection: Collection, 193 + curatorId: CuratorId, 194 + removedLinkRef: PublishedRecordId, 195 + ): Promise<Result<PublishedRecordId, UseCaseError>> { 196 + // ... validation and auth ... 197 + const removalRecordDTO = CollectionLinkRemovalMapper.toCreateRecordDTO( 198 + collection.publishedRecordId.getValue(), 199 + removedLinkRef.getValue(), 200 + ); 201 + const createResult = await agent.com.atproto.repo.createRecord({ 202 + repo: curatorDid.value, 203 + collection: this.collectionLinkRemovalCollection, 204 + record: removalRecordDTO, 205 + }); 206 + return ok(PublishedRecordId.create({...})); 207 + } 208 + ``` 209 + 210 + ### 4. Mapper 211 + 212 + **File:** `src/modules/atproto/infrastructure/mappers/CollectionLinkRemovalMapper.ts` (CREATED) 213 + 214 + Maps domain data to removal record DTOs: 215 + 216 + ```typescript 217 + static toCreateRecordDTO( 218 + collectionPublishedRecordId: PublishedRecordIdProps, 219 + removedLinkPublishedRecordId: PublishedRecordIdProps, 220 + ): CollectionLinkRemovalRecordDTO { 221 + const record: CollectionLinkRemovalRecordDTO = { 222 + $type: this.collectionLinkRemovalType as any, 223 + collection: { 224 + uri: collectionPublishedRecordId.uri, 225 + cid: collectionPublishedRecordId.cid, 226 + }, 227 + removedLink: { 228 + uri: removedLinkPublishedRecordId.uri, 229 + cid: removedLinkPublishedRecordId.cid, 230 + }, 231 + removedAt: new Date().toISOString(), 232 + }; 233 + return record; 234 + } 235 + ``` 236 + 237 + ## Files Modified 238 + 239 + ### Created 240 + 241 + 1. `src/modules/atproto/infrastructure/lexicons/collectionLinkRemoval.json` - Lexicon schema 242 + 2. `src/modules/atproto/infrastructure/mappers/CollectionLinkRemovalMapper.ts` - Mapper for removal records 243 + 244 + ### Modified 245 + 246 + 1. `src/modules/cards/domain/Collection.ts` - Updated `removeCard()` method with proper permissions 247 + 2. `src/modules/cards/domain/services/CardCollectionService.ts` - Updated removal logic with publishing decisions 248 + 3. `src/modules/cards/application/ports/ICollectionPublisher.ts` - Added `publishCollectionLinkRemoval` method signature 249 + 4. `src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts` - Implemented `publishCollectionLinkRemoval` 250 + 5. `src/modules/cards/tests/utils/FakeCollectionPublisher.ts` - Added test implementation for removal tracking 251 + 6. `src/shared/infrastructure/config/EnvironmentConfigService.ts` - Added `collectionLinkRemoval` config 252 + 7. `src/shared/infrastructure/http/factories/ServiceFactory.ts` - Updated ATProtoCollectionPublisher instantiation 253 + 8. `src/modules/cards/tests/application/RemoveCardFromCollectionUseCase.test.ts` - Updated/added tests 254 + 255 + ### Generated 256 + 257 + - TypeScript types regenerated via `npm run lexgen` 258 + 259 + ## Test Coverage 260 + 261 + ### Test Results 262 + 263 + - ✅ 20 tests passed in RemoveCardFromCollectionUseCase 264 + - ✅ 2 tests skipped (CLOSED collections with collaborators - out of scope) 265 + - ✅ 148 total collection-related tests passed 266 + 267 + ### Test Scenarios Added 268 + 269 + 1. **Collection owner removes contributor's card** → publishes removal record 270 + 2. **Contributor removes their own card** → unpublishes link 271 + 3. **Owner removes their own card** → unpublishes link 272 + 4. **Non-author tries to remove another user's card** → fails with permission error 273 + 274 + ### Skipped Tests (Out of Scope) 275 + 276 + The following tests were skipped as CLOSED collection scenarios with collaborators are deferred: 277 + 278 + - `should allow collaborator to remove cards from closed collection` 279 + - `should handle mixed collection permissions when removing cards` 280 + 281 + ## Enforcement Layers 282 + 283 + The removal rules are enforced at **two layers** for defense in depth: 284 + 285 + 1. **Domain Layer** (`Collection.removeCard()`) - First line of defense, encapsulates business rules 286 + 2. **Service Layer** (`CardCollectionService.removeCardFromCollection()`) - Handles ATProto publishing logic 287 + 288 + This ensures business rules are properly encapsulated in the domain model where they belong. 289 + 290 + ## Future Work 291 + 292 + ### CLOSED Collections with Collaborators 293 + 294 + Deferred for future implementation: 295 + 296 + - How collaborators can remove cards from CLOSED collections 297 + - Whether collaborators can remove any card or only their own 298 + - Permission rules for multi-user CLOSED collection scenarios 299 + 300 + ### Potential Enhancements 301 + 302 + - Add a `reason` field to collectionLinkRemoval for moderation use cases 303 + - Implement bulk removal operations 304 + - Add notifications when cards are removed from collections 305 + - Query support for fetching removal records from the firehose 306 + 307 + ## Configuration 308 + 309 + Added environment configuration for the collectionLinkRemoval collection: 310 + 311 + ```typescript 312 + collections: { 313 + card: 'network.cosmik.card', 314 + collection: 'network.cosmik.collection', 315 + collectionLink: 'network.cosmik.collectionLink', 316 + collectionLinkRemoval: 'network.cosmik.collectionLinkRemoval', // NEW 317 + } 318 + ``` 319 + 320 + ## References 321 + 322 + - ATProto Documentation: https://atproto.com/ 323 + - Lexicon Specification: https://atproto.com/specs/lexicon 324 + - StrongRef Definition: `com.atproto.repo.strongRef` 325 + 326 + ## Notes 327 + 328 + - The implementation focuses exclusively on OPEN collections as specified by the user 329 + - Permission checks occur before any ATProto publishing to prevent unauthorized operations 330 + - The design follows the vertical slice architecture pattern used throughout the codebase 331 + - Error handling uses the Result/Either pattern for type-safe error propagation
+327
.agent/logs/20260123_domain_event_processing.md
··· 1 + # Domain Events Guide 2 + 3 + This document summarizes how domain events are structured, published, and handled in the codebase, along with a checklist for adding new events. 4 + 5 + --- 6 + 7 + ## Architecture Overview 8 + 9 + The event system uses **BullMQ** (Redis-backed queues) for asynchronous event processing. Events flow through these layers: 10 + 11 + ``` 12 + Domain Entity → Use Case → Event Publisher → Redis Queue → Worker → Event Handler/Saga 13 + ``` 14 + 15 + --- 16 + 17 + ## Key Files & Components 18 + 19 + | Component | Location | Purpose | 20 + | -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------ | 21 + | Event Names Registry | `src/shared/infrastructure/events/EventConfig.ts` | Central registry of all event type names | 22 + | Event Publisher | `src/shared/infrastructure/events/BullMQEventPublisher.ts` | Routes events to appropriate queues | 23 + | Event Subscriber | `src/shared/infrastructure/events/BullMQEventSubscriber.ts` | Consumes events from queues and dispatches to handlers | 24 + | Event Mapper | `src/shared/infrastructure/events/EventMapper.ts` | Serializes/deserializes events for transport | 25 + | Queue Config | `src/shared/infrastructure/events/QueueConfig.ts` | Queue names and options | 26 + | Worker Process | `src/shared/infrastructure/processes/NotificationWorkerProcess.ts` | Initializes subscribers and registers handlers | 27 + 28 + --- 29 + 30 + ## Checklist: Adding a New Domain Event 31 + 32 + ### 1. Register the Event Name 33 + 34 + **File:** `src/shared/infrastructure/events/EventConfig.ts` 35 + 36 + ```typescript 37 + export const EventNames = { 38 + // ... existing events 39 + YOUR_NEW_EVENT: 'YourNewEventEvent', 40 + } as const; 41 + ``` 42 + 43 + ### 2. Create the Domain Event Class 44 + 45 + **Location:** `src/modules/<module>/domain/events/YourNewEvent.ts` 46 + 47 + Required structure: 48 + 49 + ```typescript 50 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 51 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 52 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 53 + import { Result, ok } from '../../../../shared/core/Result'; 54 + 55 + export class YourNewEvent implements IDomainEvent { 56 + public readonly eventName = EventNames.YOUR_NEW_EVENT; 57 + public readonly dateTimeOccurred: Date; 58 + 59 + private constructor( 60 + public readonly someId: SomeValueObject, 61 + dateTimeOccurred?: Date, 62 + ) { 63 + this.dateTimeOccurred = dateTimeOccurred || new Date(); 64 + } 65 + 66 + public static create(someId: SomeValueObject): Result<YourNewEvent> { 67 + return ok(new YourNewEvent(someId)); 68 + } 69 + 70 + public static reconstruct( 71 + someId: SomeValueObject, 72 + dateTimeOccurred: Date, 73 + ): Result<YourNewEvent> { 74 + return ok(new YourNewEvent(someId, dateTimeOccurred)); 75 + } 76 + 77 + getAggregateId(): UniqueEntityID { 78 + return this.someId.getValue(); 79 + } 80 + } 81 + ``` 82 + 83 + ### 3. Raise the Event in the Domain Entity 84 + 85 + **Location:** Domain entity method (e.g., `src/modules/cards/domain/Card.ts`) 86 + 87 + ```typescript 88 + import { YourNewEvent } from './events/YourNewEvent'; 89 + 90 + // Inside the domain method that triggers the event: 91 + public doSomething(): Result<void> { 92 + // ... business logic ... 93 + 94 + const domainEvent = YourNewEvent.create(this.someId); 95 + if (domainEvent.isErr()) { 96 + return err(new ValidationError(domainEvent.error.message)); 97 + } 98 + this.addDomainEvent(domainEvent.value); 99 + 100 + return ok(undefined); 101 + } 102 + ``` 103 + 104 + ### 4. Update the Event Mapper 105 + 106 + **File:** `src/shared/infrastructure/events/EventMapper.ts` 107 + 108 + **Step 4a:** Add the import: 109 + 110 + ```typescript 111 + import { YourNewEvent } from '../../../modules/<module>/domain/events/YourNewEvent'; 112 + ``` 113 + 114 + **Step 4b:** Add the serialized interface: 115 + 116 + ```typescript 117 + export interface SerializedYourNewEvent extends SerializedEvent { 118 + eventType: typeof EventNames.YOUR_NEW_EVENT; 119 + someId: string; 120 + // ... other properties 121 + } 122 + ``` 123 + 124 + **Step 4c:** Add to the union type: 125 + 126 + ```typescript 127 + export type SerializedEventUnion = 128 + | SerializedCardAddedToLibraryEvent 129 + // ... other events 130 + | SerializedYourNewEvent; 131 + ``` 132 + 133 + **Step 4d:** Add serialization in `toSerialized()`: 134 + 135 + ```typescript 136 + if (event instanceof YourNewEvent) { 137 + return { 138 + eventType: EventNames.YOUR_NEW_EVENT, 139 + aggregateId: event.getAggregateId().toString(), 140 + dateTimeOccurred: event.dateTimeOccurred.toISOString(), 141 + someId: event.someId.getValue().toString(), 142 + }; 143 + } 144 + ``` 145 + 146 + **Step 4e:** Add deserialization in `fromSerialized()`: 147 + 148 + ```typescript 149 + case EventNames.YOUR_NEW_EVENT: { 150 + const someId = SomeId.createFromString(eventData.someId).unwrap(); 151 + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); 152 + return YourNewEvent.reconstruct(someId, dateTimeOccurred).unwrap(); 153 + } 154 + ``` 155 + 156 + ### 5. Configure Queue Routing 157 + 158 + **File:** `src/shared/infrastructure/events/BullMQEventPublisher.ts` 159 + 160 + Update `getTargetQueues()` to route the event to appropriate queues: 161 + 162 + ```typescript 163 + private getTargetQueues(eventName: EventName): QueueName[] { 164 + switch (eventName) { 165 + // ... existing cases 166 + case EventNames.YOUR_NEW_EVENT: 167 + return [QueueNames.NOTIFICATIONS]; // or multiple queues 168 + default: 169 + return [QueueNames.FEEDS]; 170 + } 171 + } 172 + ``` 173 + 174 + ### 6. Publish Events from the Use Case 175 + 176 + **File:** The use case that performs the action (e.g., `src/modules/<module>/application/useCases/commands/YourUseCase.ts`) 177 + 178 + Extend `BaseUseCase` and call `publishEventsForAggregate()`: 179 + 180 + ```typescript 181 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 182 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 183 + 184 + export class YourUseCase extends BaseUseCase<RequestDTO, Result<ResponseDTO>> { 185 + constructor( 186 + private repository: IRepository, 187 + eventPublisher: IEventPublisher, 188 + ) { 189 + super(eventPublisher); 190 + } 191 + 192 + async execute(request: RequestDTO): Promise<Result<ResponseDTO>> { 193 + // ... business logic that raises domain events ... 194 + 195 + // Publish events for the aggregate 196 + const publishResult = await this.publishEventsForAggregate(entity); 197 + if (publishResult.isErr()) { 198 + console.error('Failed to publish events:', publishResult.error); 199 + // Don't fail the operation if event publishing fails 200 + } 201 + 202 + return ok(response); 203 + } 204 + } 205 + ``` 206 + 207 + ### 7. Update UseCaseFactory 208 + 209 + **File:** `src/shared/infrastructure/http/factories/UseCaseFactory.ts` 210 + 211 + Pass `eventPublisher` to the use case constructor: 212 + 213 + ```typescript 214 + yourUseCase: new YourUseCase( 215 + repositories.someRepository, 216 + services.eventPublisher, 217 + ), 218 + ``` 219 + 220 + ### 8. Create the Event Handler 221 + 222 + **Location:** `src/modules/<module>/application/eventHandlers/YourNewEventHandler.ts` 223 + 224 + ```typescript 225 + import { YourNewEvent } from '../../../<module>/domain/events/YourNewEvent'; 226 + import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; 227 + import { Result, ok } from '../../../../shared/core/Result'; 228 + 229 + export class YourNewEventHandler implements IEventHandler<YourNewEvent> { 230 + constructor(private someDependency: ISomeDependency) {} 231 + 232 + async handle(event: YourNewEvent): Promise<Result<void>> { 233 + // Handle the event 234 + return ok(undefined); 235 + } 236 + } 237 + ``` 238 + 239 + ### 9. Register the Handler in the Worker Process 240 + 241 + **File:** `src/shared/infrastructure/processes/NotificationWorkerProcess.ts` (or appropriate worker) 242 + 243 + ```typescript 244 + import { YourNewEventHandler } from '../../../modules/<module>/application/eventHandlers/YourNewEventHandler'; 245 + 246 + // In the initialization: 247 + const yourNewEventHandler = new YourNewEventHandler(dependencies); 248 + 249 + await subscriber.subscribe(EventNames.YOUR_NEW_EVENT, yourNewEventHandler); 250 + ``` 251 + 252 + --- 253 + 254 + ## Event Aggregation with Sagas 255 + 256 + For events that need to be bundled or coordinated, use a saga pattern: 257 + 258 + **Example:** `src/modules/notifications/application/sagas/CardNotificationSaga.ts` 259 + 260 + Sagas can: 261 + 262 + - Aggregate multiple events within a time window 263 + - Store pending state in Redis via `ISagaStateStore` 264 + - Create consolidated notifications 265 + 266 + Handler delegates to saga: 267 + 268 + ```typescript 269 + export class YourEventHandler implements IEventHandler<YourEvent> { 270 + constructor(private saga: YourSaga) {} 271 + 272 + async handle(event: YourEvent): Promise<Result<void>> { 273 + return this.saga.handleEvent(event); 274 + } 275 + } 276 + ``` 277 + 278 + --- 279 + 280 + ## Summary Diagram 281 + 282 + ``` 283 + ┌─────────────────────────────────────────────────────────────────────┐ 284 + │ PUBLISHING SIDE │ 285 + ├─────────────────────────────────────────────────────────────────────┤ 286 + │ Domain Entity │ 287 + │ └── Raises event via this.addDomainEvent() │ 288 + │ ↓ │ 289 + │ Use Case (extends BaseUseCase) │ 290 + │ └── Calls publishEventsForAggregate() │ 291 + │ ↓ │ 292 + │ BullMQEventPublisher │ 293 + │ ├── EventMapper.toSerialized() - converts to JSON │ 294 + │ └── getTargetQueues() - routes to appropriate queue(s) │ 295 + │ ↓ │ 296 + │ Redis Queues (FEEDS, SEARCH, NOTIFICATIONS) │ 297 + └─────────────────────────────────────────────────────────────────────┘ 298 + 299 + ┌─────────────────────────────────────────────────────────────────────┐ 300 + │ CONSUMING SIDE │ 301 + ├─────────────────────────────────────────────────────────────────────┤ 302 + │ Worker Process (e.g., NotificationWorkerProcess) │ 303 + │ └── Creates BullMQEventSubscriber │ 304 + │ ↓ │ 305 + │ BullMQEventSubscriber │ 306 + │ ├── Listens to queue │ 307 + │ ├── EventMapper.fromSerialized() - reconstructs event │ 308 + │ └── Dispatches to registered handler │ 309 + │ ↓ │ 310 + │ Event Handler / Saga │ 311 + │ └── Processes event (creates notifications, updates state, etc.) │ 312 + └─────────────────────────────────────────────────────────────────────┘ 313 + ``` 314 + 315 + --- 316 + 317 + ## Quick Reference: Files to Touch for a New Event 318 + 319 + 1. `src/shared/infrastructure/events/EventConfig.ts` — Add event name 320 + 2. `src/modules/<module>/domain/events/YourNewEvent.ts` — Create event class 321 + 3. `src/modules/<module>/domain/<Entity>.ts` — Raise event in domain method 322 + 4. `src/shared/infrastructure/events/EventMapper.ts` — Add serialization/deserialization 323 + 5. `src/shared/infrastructure/events/BullMQEventPublisher.ts` — Configure queue routing 324 + 6. `src/modules/<module>/application/useCases/...UseCase.ts` — Extend BaseUseCase, publish events 325 + 7. `src/shared/infrastructure/http/factories/UseCaseFactory.ts` — Pass eventPublisher 326 + 8. `src/modules/<module>/application/eventHandlers/...Handler.ts` — Create handler 327 + 9. `src/shared/infrastructure/processes/<Worker>Process.ts` — Register handler
+1
CLAUDE.md
··· 1 1 - run `npm run build:check` to check type errors after making changes 2 2 - whenever we make changes to sql schemas (denoted by .sql. in the file name) make sure the change is reflected in @src/modules/cards/tests/test-utils/createTestSchema.ts and generate the migration files by running `npm run db:generate` 3 + - use npm run webapp:type-check to check type errors in frontend
+1
package.json
··· 56 56 "webapp:build": "cd src/webapp && npm run build", 57 57 "webapp:start": "cd src/webapp && npm run start", 58 58 "webapp:storybook": "cd src/webapp && npm run storybook", 59 + "webapp:type-check": "cd src/webapp && npm run type-check", 59 60 "format": "prettier --write .", 60 61 "format:check": "prettier --check .", 61 62 "lint": "eslint .",
+7
src/modules/atproto/application/useCases/ProcessCollectionFirehoseEventUseCase.ts
··· 8 8 import { CreateCollectionUseCase } from '../../../cards/application/useCases/commands/CreateCollectionUseCase'; 9 9 import { UpdateCollectionUseCase } from '../../../cards/application/useCases/commands/UpdateCollectionUseCase'; 10 10 import { DeleteCollectionUseCase } from '../../../cards/application/useCases/commands/DeleteCollectionUseCase'; 11 + import { CollectionAccessType } from '../../../cards/domain/Collection'; 11 12 export interface ProcessCollectionFirehoseEventDTO { 12 13 atUri: string; 13 14 cid: string | null; ··· 89 90 const result = await this.createCollectionUseCase.execute({ 90 91 name: request.record.name, 91 92 description: request.record.description, 93 + accessType: request.record.accessType as 94 + | CollectionAccessType 95 + | undefined, 92 96 curatorId: authorDid, 93 97 publishedRecordId: publishedRecordId, 94 98 createdAt: timestamp, ··· 179 183 collectionId: collectionIdResult.value.getStringValue(), 180 184 name: request.record.name, 181 185 description: request.record.description, 186 + accessType: request.record.accessType as 187 + | CollectionAccessType 188 + | undefined, 182 189 curatorId: authorDid, 183 190 publishedRecordId: publishedRecordId, 184 191 });
+150
src/modules/atproto/application/useCases/ProcessCollectionLinkRemovalFirehoseEventUseCase.ts
··· 1 + import { Result, ok, err } from 'src/shared/core/Result'; 2 + import { UseCase } from 'src/shared/core/UseCase'; 3 + import { AppError } from 'src/shared/core/AppError'; 4 + import { IAtUriResolutionService } from '../../../cards/domain/services/IAtUriResolutionService'; 5 + import { ATUri } from '../../domain/ATUri'; 6 + import { Record as CollectionLinkRemovalRecord } from '../../infrastructure/lexicon/types/network/cosmik/collectionLinkRemoval'; 7 + import { 8 + UpdateUrlCardAssociationsUseCase, 9 + OperationContext, 10 + } from '../../../cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 11 + 12 + export interface ProcessCollectionLinkRemovalFirehoseEventDTO { 13 + atUri: string; 14 + cid: string | null; 15 + eventType: 'create' | 'update' | 'delete'; 16 + record?: CollectionLinkRemovalRecord; 17 + } 18 + 19 + const ENABLE_FIREHOSE_LOGGING = true; 20 + 21 + export class ProcessCollectionLinkRemovalFirehoseEventUseCase 22 + implements UseCase<ProcessCollectionLinkRemovalFirehoseEventDTO, Result<void>> 23 + { 24 + constructor( 25 + private atUriResolutionService: IAtUriResolutionService, 26 + private updateUrlCardAssociationsUseCase: UpdateUrlCardAssociationsUseCase, 27 + ) {} 28 + 29 + async execute( 30 + request: ProcessCollectionLinkRemovalFirehoseEventDTO, 31 + ): Promise<Result<void>> { 32 + try { 33 + if (ENABLE_FIREHOSE_LOGGING) { 34 + console.log( 35 + `[FirehoseWorker] Processing collection link removal event: ${request.atUri} (${request.eventType})`, 36 + ); 37 + } 38 + 39 + switch (request.eventType) { 40 + case 'create': 41 + return await this.handleCollectionLinkRemovalCreate(request); 42 + case 'delete': 43 + // For now, we don't handle deletion events for collectionLinkRemoval 44 + if (ENABLE_FIREHOSE_LOGGING) { 45 + console.log( 46 + `[FirehoseWorker] Collection link removal delete event (not handled): ${request.atUri}`, 47 + ); 48 + } 49 + break; 50 + case 'update': 51 + // Collection link removals don't typically have update operations 52 + if (ENABLE_FIREHOSE_LOGGING) { 53 + console.log( 54 + `[FirehoseWorker] Collection link removal update event (unusual): ${request.atUri}`, 55 + ); 56 + } 57 + break; 58 + } 59 + 60 + return ok(undefined); 61 + } catch (error) { 62 + return err(AppError.UnexpectedError.create(error)); 63 + } 64 + } 65 + 66 + private async handleCollectionLinkRemovalCreate( 67 + request: ProcessCollectionLinkRemovalFirehoseEventDTO, 68 + ): Promise<Result<void>> { 69 + if (!request.record || !request.cid) { 70 + if (ENABLE_FIREHOSE_LOGGING) { 71 + console.warn( 72 + `[FirehoseWorker] Collection link removal create event missing record or cid, skipping: ${request.atUri}`, 73 + ); 74 + } 75 + return ok(undefined); 76 + } 77 + 78 + try { 79 + // Parse AT URI to extract curator DID (the person who published the removal) 80 + const atUriResult = ATUri.create(request.atUri); 81 + if (atUriResult.isErr()) { 82 + if (ENABLE_FIREHOSE_LOGGING) { 83 + console.warn( 84 + `[FirehoseWorker] Invalid AT URI format: ${request.atUri} - ${atUriResult.error.message}`, 85 + ); 86 + } 87 + return ok(undefined); 88 + } 89 + const removerDid = atUriResult.value.did.value; 90 + 91 + // Resolve the collection link that's being removed 92 + const collectionLinkUri = request.record.removedLink.uri; 93 + const linkInfoResult = 94 + await this.atUriResolutionService.resolveCollectionLinkId( 95 + collectionLinkUri, 96 + ); 97 + 98 + if (linkInfoResult.isErr()) { 99 + if (ENABLE_FIREHOSE_LOGGING) { 100 + console.warn( 101 + `[FirehoseWorker] Failed to resolve collection link - remover: ${removerDid}, collectionLinkUri: ${collectionLinkUri}, removalUri: ${request.atUri}`, 102 + ); 103 + } 104 + return ok(undefined); 105 + } 106 + 107 + if (!linkInfoResult.value) { 108 + if (ENABLE_FIREHOSE_LOGGING) { 109 + console.log( 110 + `[FirehoseWorker] Collection link not found in our system (may have already been removed) - remover: ${removerDid}, collectionLinkUri: ${collectionLinkUri}, removalUri: ${request.atUri}`, 111 + ); 112 + } 113 + return ok(undefined); 114 + } 115 + 116 + const { cardId, collectionId } = linkInfoResult.value; 117 + 118 + // Remove the card from the collection using the context flag to skip publishing 119 + const result = await this.updateUrlCardAssociationsUseCase.execute({ 120 + cardId: cardId.getStringValue(), 121 + curatorId: removerDid, 122 + removeFromCollections: [collectionId.getStringValue()], 123 + context: OperationContext.FIREHOSE_EVENT, // This tells the use case to skip publishing 124 + }); 125 + 126 + if (result.isErr()) { 127 + if (ENABLE_FIREHOSE_LOGGING) { 128 + console.warn( 129 + `[FirehoseWorker] Failed to remove card from collection - remover: ${removerDid}, cardId: ${cardId.getStringValue()}, collectionId: ${collectionId.getStringValue()}, removalUri: ${request.atUri}, error: ${result.error.message}`, 130 + ); 131 + } 132 + return ok(undefined); 133 + } 134 + 135 + if (ENABLE_FIREHOSE_LOGGING) { 136 + console.log( 137 + `[FirehoseWorker] Successfully removed card from collection via removal record - remover: ${removerDid}, cardId: ${cardId.getStringValue()}, collectionId: ${collectionId.getStringValue()}, removalUri: ${request.atUri}`, 138 + ); 139 + } 140 + return ok(undefined); 141 + } catch (error) { 142 + if (ENABLE_FIREHOSE_LOGGING) { 143 + console.error( 144 + `[FirehoseWorker] Error processing collection link removal create event - uri: ${request.atUri}, error: ${error}`, 145 + ); 146 + } 147 + return ok(undefined); // Don't fail the firehose processing 148 + } 149 + } 150 + }
+26
src/modules/atproto/application/useCases/ProcessFirehoseEventUseCase.ts
··· 11 11 import { ProcessMarginBookmarkFirehoseEventUseCase } from './ProcessMarginBookmarkFirehoseEventUseCase'; 12 12 import { ProcessMarginCollectionFirehoseEventUseCase } from './ProcessMarginCollectionFirehoseEventUseCase'; 13 13 import { ProcessMarginCollectionItemFirehoseEventUseCase } from './ProcessMarginCollectionItemFirehoseEventUseCase'; 14 + import { ProcessCollectionLinkRemovalFirehoseEventUseCase } from './ProcessCollectionLinkRemovalFirehoseEventUseCase'; 14 15 import type { RepoRecord } from '@atproto/lexicon'; 15 16 import { Record as CardRecord } from '../../infrastructure/lexicon/types/network/cosmik/card'; 16 17 import { Record as CollectionRecord } from '../../infrastructure/lexicon/types/network/cosmik/collection'; ··· 18 19 import { Record as MarginBookmarkRecord } from '../../infrastructure/lexicon/types/at/margin/bookmark'; 19 20 import { Record as MarginCollectionRecord } from '../../infrastructure/lexicon/types/at/margin/collection'; 20 21 import { Record as MarginCollectionItemRecord } from '../../infrastructure/lexicon/types/at/margin/collectionItem'; 22 + import { Record as CollectionLinkRemovalRecord } from '../../infrastructure/lexicon/types/network/cosmik/collectionLinkRemoval'; 21 23 22 24 export interface ProcessFirehoseEventDTO { 23 25 atUri: string; ··· 44 46 private processMarginBookmarkFirehoseEventUseCase: ProcessMarginBookmarkFirehoseEventUseCase, 45 47 private processMarginCollectionFirehoseEventUseCase: ProcessMarginCollectionFirehoseEventUseCase, 46 48 private processMarginCollectionItemFirehoseEventUseCase: ProcessMarginCollectionItemFirehoseEventUseCase, 49 + private processCollectionLinkRemovalFirehoseEventUseCase: ProcessCollectionLinkRemovalFirehoseEventUseCase, 47 50 ) {} 48 51 49 52 async execute(request: ProcessFirehoseEventDTO): Promise<Result<void>> { ··· 129 132 return this.processCollectionLinkFirehoseEventUseCase.execute({ 130 133 ...request, 131 134 record: request.record as CollectionLinkRecord | undefined, 135 + }); 136 + case collections.collectionLinkRemoval: 137 + // Validate CollectionLinkRemovalRecord structure 138 + if ( 139 + request.record && 140 + (request.eventType === 'create' || request.eventType === 'update') 141 + ) { 142 + const removalRecord = request.record as CollectionLinkRemovalRecord; 143 + if ( 144 + !removalRecord.collection || 145 + !removalRecord.removedLink || 146 + !removalRecord.removedAt 147 + ) { 148 + return err( 149 + new ValidationError( 150 + 'Invalid collection link removal record structure', 151 + ), 152 + ); 153 + } 154 + } 155 + return this.processCollectionLinkRemovalFirehoseEventUseCase.execute({ 156 + ...request, 157 + record: request.record as CollectionLinkRemovalRecord | undefined, 132 158 }); 133 159 case collections.marginBookmark: 134 160 // Validate MarginBookmarkRecord structure
+1 -6
src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts
··· 34 34 }); 35 35 36 36 const collections = envConfig.getAtProtoConfig().collections; 37 - publisher = new ATProtoCardPublisher( 38 - agentService, 39 - collections.card, 40 - collections.marginBookmark, 41 - ); 37 + publisher = new ATProtoCardPublisher(agentService, collections.card); 42 38 curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); 43 39 }); 44 40 ··· 495 491 const invalidPublisher = new ATProtoCardPublisher( 496 492 invalidAgentService, 497 493 collections.card, 498 - collections.marginBookmark, 499 494 ); 500 495 501 496 const invalidCuratorId = CuratorId.create('did:plc:invalid').unwrap();
+2 -4
src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts
··· 42 42 agentService, 43 43 collections.collection, 44 44 collections.collectionLink, 45 - collections.marginCollection, 46 - collections.marginCollectionItem, 45 + collections.collectionLinkRemoval, 47 46 ); 48 47 cardPublisher = new FakeCardPublisher(); 49 48 curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); ··· 539 538 invalidAgentService, 540 539 collections.collection, 541 540 collections.collectionLink, 542 - collections.marginCollection, 543 - collections.marginCollectionItem, 541 + collections.collectionLinkRemoval, 544 542 ); 545 543 546 544 const testCollection = new CollectionBuilder()
+38
src/modules/atproto/infrastructure/lexicon/lexicons.ts
··· 260 260 }, 261 261 }, 262 262 }, 263 + NetworkCosmikCollectionLinkRemoval: { 264 + lexicon: 1, 265 + id: 'network.cosmik.collectionLinkRemoval', 266 + description: 267 + 'A record indicating that a card was removed from a collection by the collection owner.', 268 + defs: { 269 + main: { 270 + type: 'record', 271 + description: 272 + "A record representing the removal of a collection link by a collection owner when they cannot delete the original link (which exists in another user's repository). The creator of this record (determined from the AT-URI) is the user who performed the removal.", 273 + key: 'tid', 274 + record: { 275 + type: 'object', 276 + required: ['collection', 'removedLink', 'removedAt'], 277 + properties: { 278 + collection: { 279 + type: 'ref', 280 + description: 'Strong reference to the collection record.', 281 + ref: 'lex:com.atproto.repo.strongRef', 282 + }, 283 + removedLink: { 284 + type: 'ref', 285 + description: 286 + 'Strong reference to the collectionLink record that is being removed.', 287 + ref: 'lex:com.atproto.repo.strongRef', 288 + }, 289 + removedAt: { 290 + type: 'string', 291 + format: 'datetime', 292 + description: 293 + 'Timestamp when the link was removed from the collection.', 294 + }, 295 + }, 296 + }, 297 + }, 298 + }, 299 + }, 263 300 NetworkCosmikDefs: { 264 301 lexicon: 1, 265 302 id: 'network.cosmik.defs', ··· 924 961 NetworkCosmikCard: 'network.cosmik.card', 925 962 NetworkCosmikCollection: 'network.cosmik.collection', 926 963 NetworkCosmikCollectionLink: 'network.cosmik.collectionLink', 964 + NetworkCosmikCollectionLinkRemoval: 'network.cosmik.collectionLinkRemoval', 927 965 NetworkCosmikDefs: 'network.cosmik.defs', 928 966 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 929 967 AtMarginAnnotation: 'at.margin.annotation',
+35
src/modules/atproto/infrastructure/lexicon/types/network/cosmik/collectionLinkRemoval.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon'; 5 + import { CID } from 'multiformats/cid'; 6 + import { validate as _validate } from '../../../lexicons'; 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../util'; 12 + import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js'; 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate; 16 + const id = 'network.cosmik.collectionLinkRemoval'; 17 + 18 + export interface Record { 19 + $type: 'network.cosmik.collectionLinkRemoval'; 20 + collection: ComAtprotoRepoStrongRef.Main; 21 + removedLink: ComAtprotoRepoStrongRef.Main; 22 + /** Timestamp when the link was removed from the collection. */ 23 + removedAt: string; 24 + [k: string]: unknown; 25 + } 26 + 27 + const hashRecord = 'main'; 28 + 29 + export function isRecord<V>(v: V) { 30 + return is$typed(v, id, hashRecord); 31 + } 32 + 33 + export function validateRecord<V>(v: V) { 34 + return validate<Record & V>(v, id, hashRecord, true); 35 + }
+33
src/modules/atproto/infrastructure/lexicons/collectionLinkRemoval.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.cosmik.collectionLinkRemoval", 4 + "description": "A record indicating that a card was removed from a collection by the collection owner.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A record representing the removal of a collection link by a collection owner when they cannot delete the original link (which exists in another user's repository). The creator of this record (determined from the AT-URI) is the user who performed the removal.", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["collection", "removedLink", "removedAt"], 13 + "properties": { 14 + "collection": { 15 + "type": "ref", 16 + "description": "Strong reference to the collection record.", 17 + "ref": "com.atproto.repo.strongRef" 18 + }, 19 + "removedLink": { 20 + "type": "ref", 21 + "description": "Strong reference to the collectionLink record that is being removed.", 22 + "ref": "com.atproto.repo.strongRef" 23 + }, 24 + "removedAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp when the link was removed from the collection." 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+32
src/modules/atproto/infrastructure/mappers/CollectionLinkRemovalMapper.ts
··· 1 + import { PublishedRecordIdProps } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 2 + import { Record } from '../lexicon/types/network/cosmik/collectionLinkRemoval'; 3 + import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 4 + 5 + type CollectionLinkRemovalRecordDTO = Record; 6 + 7 + export class CollectionLinkRemovalMapper { 8 + private static configService = new EnvironmentConfigService(); 9 + static collectionLinkRemovalType = 10 + CollectionLinkRemovalMapper.configService.getAtProtoCollections() 11 + .collectionLinkRemoval; 12 + 13 + static toCreateRecordDTO( 14 + collectionPublishedRecordId: PublishedRecordIdProps, 15 + removedLinkPublishedRecordId: PublishedRecordIdProps, 16 + ): CollectionLinkRemovalRecordDTO { 17 + const record: CollectionLinkRemovalRecordDTO = { 18 + $type: this.collectionLinkRemovalType as any, 19 + collection: { 20 + uri: collectionPublishedRecordId.uri, 21 + cid: collectionPublishedRecordId.cid, 22 + }, 23 + removedLink: { 24 + uri: removedLinkPublishedRecordId.uri, 25 + cid: removedLinkPublishedRecordId.cid, 26 + }, 27 + removedAt: new Date().toISOString(), 28 + }; 29 + 30 + return record; 31 + } 32 + }
-1
src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts
··· 14 14 constructor( 15 15 private readonly agentService: IAgentService, 16 16 private readonly cardCollection: string, 17 - private readonly marginBookmarkCollection: string, 18 17 ) {} 19 18 20 19 /**
+77 -2
src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts
··· 11 11 import { CollectionMapper } from '../mappers/CollectionMapper'; 12 12 import { MarginCollectionMapper } from '../mappers/MarginCollectionMapper'; 13 13 import { CollectionLinkMapper } from '../mappers/CollectionLinkMapper'; 14 + import { CollectionLinkRemovalMapper } from '../mappers/CollectionLinkRemovalMapper'; 14 15 import { StrongRef } from '../../domain'; 15 16 import { IAgentService } from '../../application/IAgentService'; 16 17 import { DID } from '../../domain/DID'; ··· 25 26 private readonly agentService: IAgentService, 26 27 private readonly collectionCollection: string, 27 28 private readonly collectionLinkCollection: string, 28 - private readonly marginCollectionCollection: string, 29 - private readonly marginCollectionItemCollection: string, 29 + private readonly collectionLinkRemovalCollection: string, 30 30 ) {} 31 31 32 32 /** ··· 293 293 }); 294 294 295 295 return ok(undefined); 296 + } catch (error) { 297 + return err( 298 + new Error(error instanceof Error ? error.message : String(error)), 299 + ); 300 + } 301 + } 302 + 303 + /** 304 + * Publishes a collectionLinkRemoval record when a collection owner removes a card added by someone else 305 + */ 306 + async publishCollectionLinkRemoval( 307 + card: Card, 308 + collection: Collection, 309 + curatorId: CuratorId, 310 + removedLinkRef: PublishedRecordId, 311 + ): Promise<Result<PublishedRecordId, UseCaseError>> { 312 + try { 313 + const curatorDidResult = DID.create(curatorId.value); 314 + 315 + if (curatorDidResult.isErr()) { 316 + return err( 317 + new Error(`Invalid curator DID: ${curatorDidResult.error.message}`), 318 + ); 319 + } 320 + 321 + const curatorDid = curatorDidResult.value; 322 + 323 + // Get an authenticated agent for this curator (collection owner) 324 + const agentResult = 325 + await this.agentService.getAuthenticatedAgent(curatorDid); 326 + 327 + if (agentResult.isErr()) { 328 + // Propagate authentication errors as-is 329 + if (agentResult.error instanceof AuthenticationError) { 330 + return err(agentResult.error); 331 + } 332 + return err( 333 + new Error( 334 + `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`, 335 + ), 336 + ); 337 + } 338 + 339 + const agent = agentResult.value; 340 + 341 + if (!agent) { 342 + return err(new Error('No authenticated session found for curator')); 343 + } 344 + 345 + // Ensure collection is published 346 + if (!collection.publishedRecordId) { 347 + return err( 348 + new Error('Collection must be published before removing cards'), 349 + ); 350 + } 351 + 352 + // Create the collectionLinkRemoval record DTO 353 + const removalRecordDTO = CollectionLinkRemovalMapper.toCreateRecordDTO( 354 + collection.publishedRecordId.getValue(), 355 + removedLinkRef.getValue(), 356 + ); 357 + removalRecordDTO.$type = this.collectionLinkRemovalCollection as any; 358 + 359 + const createResult = await agent.com.atproto.repo.createRecord({ 360 + repo: curatorDid.value, 361 + collection: this.collectionLinkRemovalCollection, 362 + record: removalRecordDTO, 363 + }); 364 + 365 + return ok( 366 + PublishedRecordId.create({ 367 + uri: createResult.data.uri, 368 + cid: createResult.data.cid, 369 + }), 370 + ); 296 371 } catch (error) { 297 372 return err( 298 373 new Error(error instanceof Error ? error.message : String(error)),
+2
src/modules/atproto/infrastructure/services/AtProtoJetstreamService.ts
··· 269 269 collections.card, 270 270 collections.collection, 271 271 collections.collectionLink, 272 + collections.collectionLinkRemoval, 272 273 ]; 273 274 } 274 275 return [ ··· 278 279 collections.marginBookmark, 279 280 collections.marginCollection, 280 281 collections.marginCollectionItem, 282 + collections.collectionLinkRemoval, 281 283 ]; 282 284 } 283 285
+5
src/modules/atproto/infrastructure/services/DrizzleFirehoseEventDuplicationService.ts
··· 96 96 } 97 97 return ok(linkInfoResult.value === null); 98 98 } 99 + case collections.collectionLinkRemoval: { 100 + // For removal records, we track them in published_records table 101 + // If a record exists, it hasn't been deleted 102 + return ok(records.length === 0); 103 + } 99 104 default: 100 105 return err(new Error(`Unknown collection type: ${collection}`)); 101 106 }
+8
src/modules/atproto/tests/application/ProcessFirehoseEventUseCase.test.ts
··· 6 6 import { ProcessMarginBookmarkFirehoseEventUseCase } from '../../application/useCases/ProcessMarginBookmarkFirehoseEventUseCase'; 7 7 import { ProcessMarginCollectionFirehoseEventUseCase } from '../../application/useCases/ProcessMarginCollectionFirehoseEventUseCase'; 8 8 import { ProcessMarginCollectionItemFirehoseEventUseCase } from '../../application/useCases/ProcessMarginCollectionItemFirehoseEventUseCase'; 9 + import { ProcessCollectionLinkRemovalFirehoseEventUseCase } from '../../application/useCases/ProcessCollectionLinkRemovalFirehoseEventUseCase'; 9 10 import { EnvironmentConfigService } from '../../../../shared/infrastructure/config/EnvironmentConfigService'; 10 11 import { InMemoryAtUriResolutionService } from '../../../cards/tests/utils/InMemoryAtUriResolutionService'; 11 12 import { AddUrlToLibraryUseCase } from '../../../cards/application/useCases/commands/AddUrlToLibraryUseCase'; ··· 36 37 let processMarginBookmarkFirehoseEventUseCase: ProcessMarginBookmarkFirehoseEventUseCase; 37 38 let processMarginCollectionFirehoseEventUseCase: ProcessMarginCollectionFirehoseEventUseCase; 38 39 let processMarginCollectionItemFirehoseEventUseCase: ProcessMarginCollectionItemFirehoseEventUseCase; 40 + let processCollectionLinkRemovalFirehoseEventUseCase: ProcessCollectionLinkRemovalFirehoseEventUseCase; 39 41 40 42 // Dependencies for real use cases 41 43 let atUriResolutionService: InMemoryAtUriResolutionService; ··· 164 166 atUriResolutionService, 165 167 updateUrlCardAssociationsUseCase, 166 168 ); 169 + processCollectionLinkRemovalFirehoseEventUseCase = 170 + new ProcessCollectionLinkRemovalFirehoseEventUseCase( 171 + atUriResolutionService, 172 + updateUrlCardAssociationsUseCase, 173 + ); 167 174 168 175 useCase = new ProcessFirehoseEventUseCase( 169 176 duplicationService, ··· 174 181 processMarginBookmarkFirehoseEventUseCase, 175 182 processMarginCollectionFirehoseEventUseCase, 176 183 processMarginCollectionItemFirehoseEventUseCase, 184 + processCollectionLinkRemovalFirehoseEventUseCase, 177 185 ); 178 186 }); 179 187
+7
src/modules/cards/application/ports/ICollectionPublisher.ts
··· 23 23 unpublishCardAddedToCollection( 24 24 recordId: PublishedRecordId, 25 25 ): Promise<Result<void, UseCaseError>>; 26 + 27 + publishCollectionLinkRemoval( 28 + card: Card, 29 + collection: Collection, 30 + curatorId: CuratorId, 31 + removedLinkRef: PublishedRecordId, 32 + ): Promise<Result<PublishedRecordId, UseCaseError>>; 26 33 }
+2 -1
src/modules/cards/application/useCases/commands/CreateCollectionUseCase.ts
··· 12 12 export interface CreateCollectionDTO { 13 13 name: string; 14 14 description?: string; 15 + accessType?: CollectionAccessType; 15 16 curatorId: string; 16 17 publishedRecordId?: PublishedRecordId; // For firehose events - skip publishing if provided 17 18 createdAt?: Date; // For firehose events - use historical timestamp from AT Protocol record ··· 68 69 authorId: curatorId, 69 70 name: request.name, 70 71 description: request.description, 71 - accessType: CollectionAccessType.CLOSED, 72 + accessType: request.accessType ?? CollectionAccessType.CLOSED, // Default to CLOSED if not specified 72 73 collaboratorIds: [], 73 74 createdAt: timestamp, 74 75 updatedAt: timestamp,
+26 -12
src/modules/cards/application/useCases/commands/RemoveCardFromCollectionUseCase.ts
··· 1 1 import { Result, ok, err } from '../../../../../shared/core/Result'; 2 - import { UseCase } from '../../../../../shared/core/UseCase'; 2 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 3 3 import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 4 4 import { AppError } from '../../../../../shared/core/AppError'; 5 5 import { ICardRepository } from '../../../domain/ICardRepository'; ··· 8 8 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 9 9 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 10 10 import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 11 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 11 12 12 13 export interface RemoveCardFromCollectionDTO { 13 14 cardId: string; ··· 25 26 } 26 27 } 27 28 28 - export class RemoveCardFromCollectionUseCase 29 - implements 30 - UseCase< 31 - RemoveCardFromCollectionDTO, 32 - Result< 33 - RemoveCardFromCollectionResponseDTO, 34 - ValidationError | AuthenticationError | AppError.UnexpectedError 35 - > 36 - > 37 - { 29 + export class RemoveCardFromCollectionUseCase extends BaseUseCase< 30 + RemoveCardFromCollectionDTO, 31 + Result< 32 + RemoveCardFromCollectionResponseDTO, 33 + ValidationError | AuthenticationError | AppError.UnexpectedError 34 + > 35 + > { 38 36 constructor( 39 37 private cardRepository: ICardRepository, 40 38 private cardCollectionService: CardCollectionService, 41 - ) {} 39 + eventPublisher: IEventPublisher, 40 + ) { 41 + super(eventPublisher); 42 + } 42 43 43 44 async execute( 44 45 request: RemoveCardFromCollectionDTO, ··· 115 116 return err( 116 117 new ValidationError(removeFromCollectionsResult.error.message), 117 118 ); 119 + } 120 + 121 + // Publish events for each updated collection 122 + const updatedCollections = removeFromCollectionsResult.value; 123 + for (const collection of updatedCollections) { 124 + const publishResult = await this.publishEventsForAggregate(collection); 125 + if (publishResult.isErr()) { 126 + console.error( 127 + 'Failed to publish events for collection:', 128 + publishResult.error, 129 + ); 130 + // Don't fail the operation if event publishing fails 131 + } 118 132 } 119 133 120 134 return ok({
+13
src/modules/cards/application/useCases/commands/UpdateCollectionUseCase.ts
··· 8 8 import { PublishedRecordId } from '../../../domain/value-objects/PublishedRecordId'; 9 9 import { ICollectionPublisher } from '../../ports/ICollectionPublisher'; 10 10 import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 11 + import { CollectionAccessType } from '../../../domain/Collection'; 11 12 12 13 export interface UpdateCollectionDTO { 13 14 collectionId: string; 14 15 name: string; 15 16 description?: string; 17 + accessType?: CollectionAccessType; 16 18 curatorId: string; 17 19 publishedRecordId?: PublishedRecordId; // For firehose events - skip republishing if provided 18 20 } ··· 105 107 ); 106 108 if (updateResult.isErr()) { 107 109 return err(new ValidationError(updateResult.error.message)); 110 + } 111 + 112 + // Update accessType if provided 113 + if (request.accessType !== undefined) { 114 + const accessTypeResult = collection.changeAccessType( 115 + request.accessType, 116 + curatorId, 117 + ); 118 + if (accessTypeResult.isErr()) { 119 + return err(new ValidationError(accessTypeResult.error.message)); 120 + } 108 121 } 109 122 110 123 // Handle republishing - skip if publishedRecordId provided (firehose event)
+4
src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts
··· 148 148 uri: collectionUri, 149 149 name: collection.name.value, 150 150 description: collection.description?.value, 151 + accessType: collection.accessType, 151 152 author: { 152 153 id: authorProfile.id, 153 154 name: authorProfile.name, ··· 155 156 avatarUrl: authorProfile.avatarUrl, 156 157 }, 157 158 urlCards: enrichedCards, 159 + cardCount: collection.cardCount, 160 + createdAt: collection.createdAt.toISOString(), 161 + updatedAt: collection.updatedAt.toISOString(), 158 162 pagination: { 159 163 currentPage: page, 160 164 totalPages: Math.ceil(cardsResult.totalCount / limit),
+1
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
··· 141 141 uri: item.uri, 142 142 name: item.name, 143 143 description: item.description, 144 + accessType: collection.accessType, 144 145 author, 145 146 cardCount: collection.cardCount, 146 147 createdAt: collection.createdAt.toISOString(),
+2
src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts
··· 13 13 PaginationDTO, 14 14 CollectionSortingDTO, 15 15 } from '@semble/types'; 16 + import { CollectionAccessType } from '../../../domain/Collection'; 16 17 17 18 export interface GetCollectionsQuery { 18 19 curatorId: string; ··· 103 104 uri: item.uri, 104 105 name: item.name, 105 106 description: item.description, 107 + accessType: item.accessType as CollectionAccessType, 106 108 updatedAt: item.updatedAt.toISOString(), 107 109 createdAt: item.createdAt.toISOString(), 108 110 cardCount: item.cardCount,
+160
src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts
··· 1 + import { err, ok, Result } from 'src/shared/core/Result'; 2 + import { UseCase } from 'src/shared/core/UseCase'; 3 + import { 4 + ICollectionQueryRepository, 5 + CollectionSortField, 6 + SortOrder, 7 + } from '../../../domain/ICollectionQueryRepository'; 8 + import { IProfileService } from 'src/modules/cards/domain/services/IProfileService'; 9 + import { 10 + CollectionDTO, 11 + PaginationDTO, 12 + CollectionSortingDTO, 13 + } from '@semble/types'; 14 + import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; 15 + import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 16 + import { CollectionAccessType } from '../../../domain/Collection'; 17 + 18 + export interface GetOpenCollectionsWithContributorQuery { 19 + contributorId: string; // DID or handle 20 + page?: number; 21 + limit?: number; 22 + sortBy?: CollectionSortField; 23 + sortOrder?: SortOrder; 24 + } 25 + 26 + export interface GetOpenCollectionsWithContributorResult { 27 + collections: CollectionDTO[]; 28 + pagination: PaginationDTO; 29 + sorting: CollectionSortingDTO; 30 + } 31 + 32 + export class ValidationError extends Error { 33 + constructor(message: string) { 34 + super(message); 35 + this.name = 'ValidationError'; 36 + } 37 + } 38 + 39 + export class GetOpenCollectionsWithContributorUseCase 40 + implements 41 + UseCase< 42 + GetOpenCollectionsWithContributorQuery, 43 + Result<GetOpenCollectionsWithContributorResult> 44 + > 45 + { 46 + constructor( 47 + private collectionQueryRepo: ICollectionQueryRepository, 48 + private profileService: IProfileService, 49 + private identityResolver: IIdentityResolutionService, 50 + ) {} 51 + 52 + async execute( 53 + query: GetOpenCollectionsWithContributorQuery, 54 + ): Promise<Result<GetOpenCollectionsWithContributorResult>> { 55 + // Set defaults 56 + const page = query.page || 1; 57 + const limit = Math.min(query.limit || 20, 100); // Cap at 100 58 + const sortBy = query.sortBy || CollectionSortField.UPDATED_AT; 59 + const sortOrder = query.sortOrder || SortOrder.DESC; 60 + 61 + // Parse and validate contributor identifier 62 + const identifierResult = DIDOrHandle.create(query.contributorId); 63 + if (identifierResult.isErr()) { 64 + return err(new ValidationError('Invalid contributor identifier')); 65 + } 66 + 67 + // Resolve to DID 68 + const didResult = await this.identityResolver.resolveToDID( 69 + identifierResult.value, 70 + ); 71 + if (didResult.isErr()) { 72 + return err( 73 + new ValidationError( 74 + `Could not resolve contributor identifier: ${didResult.error.message}`, 75 + ), 76 + ); 77 + } 78 + 79 + const contributorDid = didResult.value.value; 80 + 81 + try { 82 + // Execute query to get raw collection data 83 + const result = 84 + await this.collectionQueryRepo.getOpenCollectionsWithContributor({ 85 + contributorId: contributorDid, 86 + page, 87 + limit, 88 + sortBy, 89 + sortOrder, 90 + }); 91 + 92 + // Get unique author IDs from the results 93 + const authorIds = [...new Set(result.items.map((item) => item.authorId))]; 94 + 95 + // Fetch profiles for all authors 96 + const profilePromises = authorIds.map((authorId) => 97 + this.profileService.getProfile(authorId), 98 + ); 99 + const profileResults = await Promise.all(profilePromises); 100 + 101 + // Create a map of authorId to profile for quick lookup 102 + const profileMap = new Map(); 103 + profileResults.forEach((profileResult, index) => { 104 + if (profileResult.isOk()) { 105 + profileMap.set(authorIds[index], profileResult.value); 106 + } 107 + }); 108 + 109 + // Transform raw data to enriched DTOs 110 + const enrichedCollections: CollectionDTO[] = result.items 111 + .map((item) => { 112 + const profile = profileMap.get(item.authorId); 113 + if (!profile) { 114 + // Skip collections where we couldn't fetch the profile 115 + return null; 116 + } 117 + 118 + return { 119 + id: item.id, 120 + uri: item.uri, 121 + name: item.name, 122 + description: item.description, 123 + accessType: CollectionAccessType.OPEN, // Always OPEN for this query 124 + updatedAt: item.updatedAt.toISOString(), 125 + createdAt: item.createdAt.toISOString(), 126 + cardCount: item.cardCount, 127 + author: { 128 + id: profile.id, 129 + name: profile.name, 130 + handle: profile.handle, 131 + avatarUrl: profile.avatarUrl, 132 + description: profile.bio, 133 + }, 134 + }; 135 + }) 136 + .filter((item): item is NonNullable<typeof item> => item !== null); 137 + 138 + return ok({ 139 + collections: enrichedCollections, 140 + pagination: { 141 + currentPage: page, 142 + totalPages: Math.ceil(result.totalCount / limit), 143 + totalCount: result.totalCount, 144 + hasMore: page * limit < result.totalCount, 145 + limit, 146 + }, 147 + sorting: { 148 + sortBy, 149 + sortOrder, 150 + }, 151 + }); 152 + } catch (error) { 153 + return err( 154 + new Error( 155 + `Failed to retrieve open collections with contributor: ${error instanceof Error ? error.message : 'Unknown error'}`, 156 + ), 157 + ); 158 + } 159 + } 160 + }
+1
src/modules/cards/application/useCases/queries/GetUrlCardViewUseCase.ts
··· 154 154 uri: fullCollection.publishedRecordId?.uri, 155 155 name: collection.name, 156 156 description: fullCollection.description?.value, 157 + accessType: fullCollection.accessType, 157 158 author: { 158 159 id: collectionAuthor.id, 159 160 name: collectionAuthor.name,
+1
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts
··· 196 196 uri: collection.uri, 197 197 name: collection.name, 198 198 description: collection.description, 199 + accessType: fullCollection.accessType, 199 200 author: { 200 201 id: authorProfile.id, 201 202 name: authorProfile.name,
+1
src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts
··· 124 124 uri: item.uri, 125 125 name: item.name, 126 126 description: item.description, 127 + accessType: item.accessType as CollectionAccessType, 127 128 updatedAt: item.updatedAt.toISOString(), 128 129 createdAt: item.createdAt.toISOString(), 129 130 cardCount: item.cardCount,
+74 -6
src/modules/cards/domain/Collection.ts
··· 8 8 import { CollectionDescription } from './value-objects/CollectionDescription'; 9 9 import { PublishedRecordId } from './value-objects/PublishedRecordId'; 10 10 import { CardAddedToCollectionEvent } from './events/CardAddedToCollectionEvent'; 11 + import { CardRemovedFromCollectionEvent } from './events/CardRemovedFromCollectionEvent'; 11 12 import { CollectionCreatedEvent } from './events/CollectionCreatedEvent'; 12 13 13 14 export interface CardLink { ··· 199 200 ); 200 201 } 201 202 203 + public canRemoveCard(cardId: CardId, userId: CuratorId): boolean { 204 + // Find the card link to check who added it 205 + const cardLink = this.props.cardLinks.find((link) => 206 + link.cardId.equals(cardId), 207 + ); 208 + 209 + // If card is not in collection, removal is allowed (no-op) 210 + if (!cardLink) { 211 + return true; 212 + } 213 + 214 + // Check removal permissions based on collection type and user role 215 + const isAuthor = this.props.authorId.equals(userId); 216 + const isUserRemovingOwnCard = cardLink.addedBy.equals(userId); 217 + 218 + if (this.isOpen) { 219 + // For OPEN collections: 220 + // - Author can remove any card 221 + // - Users can only remove cards they added themselves 222 + return isAuthor || isUserRemovingOwnCard; 223 + } else { 224 + // For CLOSED collections: 225 + // Use the standard canAddCard check (author + collaborators) 226 + // Note: CLOSED collection removal scenarios with collaborators are deferred 227 + return this.canAddCard(userId); 228 + } 229 + } 230 + 202 231 public addCard( 203 232 cardId: CardId, 204 233 userId: CuratorId, ··· 264 293 cardId: CardId, 265 294 userId: CuratorId, 266 295 ): Result<void, CollectionAccessError> { 267 - if (!this.canAddCard(userId)) { 268 - return err( 269 - new CollectionAccessError( 270 - 'User does not have permission to remove cards from this collection', 271 - ), 272 - ); 296 + // Find the card link to check who added it 297 + const cardLink = this.props.cardLinks.find((link) => 298 + link.cardId.equals(cardId), 299 + ); 300 + 301 + // If card is not in collection, removal is a no-op (succeeds idempotently) 302 + if (!cardLink) { 303 + return ok(undefined); 304 + } 305 + 306 + // Check removal permissions based on collection type and user role 307 + const isAuthor = this.props.authorId.equals(userId); 308 + const isUserRemovingOwnCard = cardLink.addedBy.equals(userId); 309 + 310 + if (this.isOpen) { 311 + // For OPEN collections: 312 + // - Author can remove any card 313 + // - Users can only remove cards they added themselves 314 + if (!isAuthor && !isUserRemovingOwnCard) { 315 + return err( 316 + new CollectionAccessError( 317 + 'User does not have permission to remove cards from this collection', 318 + ), 319 + ); 320 + } 321 + } else { 322 + // For CLOSED collections: 323 + // Use the standard canAddCard check (author + collaborators) 324 + // Note: CLOSED collection removal scenarios with collaborators are deferred 325 + if (!this.canAddCard(userId)) { 326 + return err( 327 + new CollectionAccessError( 328 + 'User does not have permission to remove cards from this collection', 329 + ), 330 + ); 331 + } 273 332 } 274 333 275 334 this.props.cardLinks = this.props.cardLinks.filter( ··· 277 336 ); 278 337 this.props.cardCount = this.props.cardLinks.length; 279 338 this.props.updatedAt = new Date(); 339 + 340 + // Raise domain event 341 + this.addDomainEvent( 342 + CardRemovedFromCollectionEvent.create( 343 + cardId, 344 + this.collectionId, 345 + userId, 346 + ).unwrap(), 347 + ); 280 348 281 349 return ok(undefined); 282 350 }
+6 -1
src/modules/cards/domain/ICardQueryRepository.ts
··· 60 60 export type CollectionCardQueryResultDTO = UrlCardView; 61 61 // Raw data from repository - minimal, just what's stored 62 62 export interface WithCollections { 63 - collections: { id: string; name: string; authorId: string }[]; 63 + collections: { 64 + id: string; 65 + name: string; 66 + authorId: string; 67 + accessType: string; 68 + }[]; 64 69 } 65 70 66 71 export interface WithLibraries {
+15
src/modules/cards/domain/ICollectionQueryRepository.ts
··· 29 29 uri?: string; 30 30 name: string; 31 31 description?: string; 32 + accessType: string; 32 33 updatedAt: Date; 33 34 createdAt: Date; 34 35 cardCount: number; ··· 48 49 uri?: string; 49 50 name: string; 50 51 description?: string; 52 + accessType: string; 51 53 authorId: string; 52 54 } 53 55 ··· 57 59 uri?: string; 58 60 name: string; 59 61 description?: string; 62 + accessType: string; 60 63 author: { 61 64 id: string; 62 65 name: string; ··· 82 85 accessType?: string; // Filter by access type (OPEN or CLOSED) 83 86 } 84 87 88 + export interface GetOpenCollectionsWithContributorOptions { 89 + contributorId: string; // User DID who contributed cards 90 + page: number; 91 + limit: number; 92 + sortBy: CollectionSortField; 93 + sortOrder: SortOrder; 94 + } 95 + 85 96 export interface ICollectionQueryRepository { 86 97 findByCreator( 87 98 curatorId: string, ··· 100 111 101 112 searchCollections( 102 113 options: SearchCollectionsOptions, 114 + ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>>; 115 + 116 + getOpenCollectionsWithContributor( 117 + options: GetOpenCollectionsWithContributorOptions, 103 118 ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>>; 104 119 }
+51
src/modules/cards/domain/events/CardRemovedFromCollectionEvent.ts
··· 1 + import { IDomainEvent } from '../../../../shared/domain/events/IDomainEvent'; 2 + import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 3 + import { CardId } from '../value-objects/CardId'; 4 + import { CollectionId } from '../value-objects/CollectionId'; 5 + import { CuratorId } from '../value-objects/CuratorId'; 6 + import { EventNames } from '../../../../shared/infrastructure/events/EventConfig'; 7 + import { Result, ok } from '../../../../shared/core/Result'; 8 + 9 + export class CardRemovedFromCollectionEvent implements IDomainEvent { 10 + public readonly eventName = EventNames.CARD_REMOVED_FROM_COLLECTION; 11 + public readonly dateTimeOccurred: Date; 12 + 13 + private constructor( 14 + public readonly cardId: CardId, 15 + public readonly collectionId: CollectionId, 16 + public readonly removedBy: CuratorId, 17 + dateTimeOccurred?: Date, 18 + ) { 19 + this.dateTimeOccurred = dateTimeOccurred || new Date(); 20 + } 21 + 22 + public static create( 23 + cardId: CardId, 24 + collectionId: CollectionId, 25 + removedBy: CuratorId, 26 + ): Result<CardRemovedFromCollectionEvent> { 27 + return ok( 28 + new CardRemovedFromCollectionEvent(cardId, collectionId, removedBy), 29 + ); 30 + } 31 + 32 + public static reconstruct( 33 + cardId: CardId, 34 + collectionId: CollectionId, 35 + removedBy: CuratorId, 36 + dateTimeOccurred: Date, 37 + ): Result<CardRemovedFromCollectionEvent> { 38 + return ok( 39 + new CardRemovedFromCollectionEvent( 40 + cardId, 41 + collectionId, 42 + removedBy, 43 + dateTimeOccurred, 44 + ), 45 + ); 46 + } 47 + 48 + getAggregateId(): UniqueEntityID { 49 + return this.collectionId.getValue(); 50 + } 51 + }
+55 -10
src/modules/cards/domain/services/CardCollectionService.ts
··· 212 212 return ok(null); 213 213 } 214 214 215 - // Handle unpublishing based on options 215 + // Check permissions FIRST before attempting any publishing operations 216 + if (!collection.canRemoveCard(card.cardId, curatorId)) { 217 + return err( 218 + new CardCollectionValidationError( 219 + 'User does not have permission to remove cards from this collection', 220 + ), 221 + ); 222 + } 223 + 224 + // Handle unpublishing/removal based on options 216 225 if (!options?.skipPublishing && cardLink.publishedRecordId) { 217 - const unpublishLinkResult = 218 - await this.collectionPublisher.unpublishCardAddedToCollection( 219 - cardLink.publishedRecordId, 220 - ); 221 - if (unpublishLinkResult.isErr()) { 222 - // Propagate authentication errors 223 - if (unpublishLinkResult.error instanceof AuthenticationError) { 224 - return err(unpublishLinkResult.error); 226 + // Determine if this is a user removing their own card or collection author removing someone else's card 227 + const isUserRemovingOwnCard = cardLink.addedBy.equals(curatorId); 228 + const isCollectionAuthor = collection.authorId.equals(curatorId); 229 + 230 + if (isUserRemovingOwnCard) { 231 + // User is removing their own card - unpublish the CollectionLink (delete from their repo) 232 + const unpublishLinkResult = 233 + await this.collectionPublisher.unpublishCardAddedToCollection( 234 + cardLink.publishedRecordId, 235 + ); 236 + if (unpublishLinkResult.isErr()) { 237 + // Propagate authentication errors 238 + if (unpublishLinkResult.error instanceof AuthenticationError) { 239 + return err(unpublishLinkResult.error); 240 + } 241 + return err( 242 + new CardCollectionValidationError( 243 + `Failed to unpublish collection link: ${unpublishLinkResult.error.message}`, 244 + ), 245 + ); 246 + } 247 + } else if (isCollectionAuthor) { 248 + // Collection author is removing someone else's card - publish a CollectionLinkRemoval record 249 + // This is the ONLY case where removal records are published 250 + const publishRemovalResult = 251 + await this.collectionPublisher.publishCollectionLinkRemoval( 252 + card, 253 + collection, 254 + curatorId, 255 + cardLink.publishedRecordId, 256 + ); 257 + if (publishRemovalResult.isErr()) { 258 + // Propagate authentication errors 259 + if (publishRemovalResult.error instanceof AuthenticationError) { 260 + return err(publishRemovalResult.error); 261 + } 262 + return err( 263 + new CardCollectionValidationError( 264 + `Failed to publish collection link removal: ${publishRemovalResult.error.message}`, 265 + ), 266 + ); 225 267 } 268 + } else { 269 + // This should never happen because permissions are checked above 270 + // If someone is neither the card adder nor the collection author, they shouldn't have permission 226 271 return err( 227 272 new CardCollectionValidationError( 228 - `Failed to unpublish collection link: ${unpublishLinkResult.error.message}`, 273 + 'User does not have permission to remove this card from the collection', 229 274 ), 230 275 ); 231 276 }
+16 -1
src/modules/cards/infrastructure/http/controllers/CreateCollectionController.ts
··· 3 3 import { CreateCollectionUseCase } from '../../../application/useCases/commands/CreateCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 5 import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 6 + import { CollectionAccessType } from '../../../domain/Collection'; 6 7 7 8 export class CreateCollectionController extends Controller { 8 9 constructor(private createCollectionUseCase: CreateCollectionUseCase) { ··· 11 12 12 13 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 13 14 try { 14 - const { name, description } = req.body; 15 + const { name, description, accessType } = req.body; 15 16 const curatorId = req.did; 16 17 17 18 if (!curatorId) { ··· 22 23 return this.badRequest(res, 'Collection name is required'); 23 24 } 24 25 26 + // Validate accessType if provided 27 + if ( 28 + accessType !== undefined && 29 + !Object.values(CollectionAccessType).includes( 30 + accessType as CollectionAccessType, 31 + ) 32 + ) { 33 + return this.badRequest( 34 + res, 35 + 'Invalid accessType. Must be OPEN or CLOSED', 36 + ); 37 + } 38 + 25 39 const result = await this.createCollectionUseCase.execute({ 26 40 name, 27 41 description, 42 + accessType: accessType as CollectionAccessType | undefined, 28 43 curatorId, 29 44 }); 30 45
+43
src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController.ts
··· 1 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 + import { Request, Response } from 'express'; 3 + import { GetOpenCollectionsWithContributorUseCase } from '../../../application/useCases/queries/GetOpenCollectionsWithContributorUseCase'; 4 + import { 5 + CollectionSortField, 6 + SortOrder, 7 + } from '../../../domain/ICollectionQueryRepository'; 8 + 9 + export class GetOpenCollectionsWithContributorController extends Controller { 10 + constructor( 11 + private getOpenCollectionsWithContributorUseCase: GetOpenCollectionsWithContributorUseCase, 12 + ) { 13 + super(); 14 + } 15 + 16 + async executeImpl(req: Request, res: Response): Promise<any> { 17 + try { 18 + const { identifier } = req.params; 19 + const { page, limit, sortBy, sortOrder } = req.query; 20 + 21 + if (!identifier) { 22 + return this.fail(res, 'Identifier (DID or handle) is required'); 23 + } 24 + 25 + const result = 26 + await this.getOpenCollectionsWithContributorUseCase.execute({ 27 + contributorId: identifier, 28 + page: page ? parseInt(page as string) : undefined, 29 + limit: limit ? parseInt(limit as string) : undefined, 30 + sortBy: sortBy as CollectionSortField, 31 + sortOrder: sortOrder as SortOrder, 32 + }); 33 + 34 + if (result.isErr()) { 35 + return this.fail(res, result.error); 36 + } 37 + 38 + return this.ok(res, result.value); 39 + } catch (error: any) { 40 + return this.fail(res, error); 41 + } 42 + } 43 + }
+16 -1
src/modules/cards/infrastructure/http/controllers/UpdateCollectionController.ts
··· 3 3 import { UpdateCollectionUseCase } from '../../../application/useCases/commands/UpdateCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 5 import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 6 + import { CollectionAccessType } from '../../../domain/Collection'; 6 7 7 8 export class UpdateCollectionController extends Controller { 8 9 constructor(private updateCollectionUseCase: UpdateCollectionUseCase) { ··· 12 13 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 13 14 try { 14 15 const { collectionId } = req.params; 15 - const { name, description } = req.body; 16 + const { name, description, accessType } = req.body; 16 17 const curatorId = req.did; 17 18 18 19 if (!curatorId) { ··· 27 28 return this.badRequest(res, 'Collection name is required'); 28 29 } 29 30 31 + // Validate accessType if provided 32 + if ( 33 + accessType !== undefined && 34 + !Object.values(CollectionAccessType).includes( 35 + accessType as CollectionAccessType, 36 + ) 37 + ) { 38 + return this.badRequest( 39 + res, 40 + 'Invalid accessType. Must be OPEN or CLOSED', 41 + ); 42 + } 43 + 30 44 const result = await this.updateCollectionUseCase.execute({ 31 45 collectionId, 32 46 name, 33 47 description, 48 + accessType: accessType as CollectionAccessType | undefined, 34 49 curatorId, 35 50 }); 36 51
+9
src/modules/cards/infrastructure/http/routes/collectionRoutes.ts
··· 8 8 import { GetCollectionPageByAtUriController } from '../controllers/GetCollectionPageByAtUriController'; 9 9 import { GetCollectionsForUrlController } from '../controllers/GetCollectionsForUrlController'; 10 10 import { SearchCollectionsController } from '../controllers/SearchCollectionsController'; 11 + import { GetOpenCollectionsWithContributorController } from '../controllers/GetOpenCollectionsWithContributorController'; 11 12 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 12 13 13 14 export function createCollectionRoutes( ··· 21 22 getCollectionPageByAtUriController: GetCollectionPageByAtUriController, 22 23 getCollectionsForUrlController: GetCollectionsForUrlController, 23 24 searchCollectionsController: SearchCollectionsController, 25 + getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, 24 26 ): Router { 25 27 const router = Router(); 26 28 ··· 43 45 // GET /api/collections/user/:identifier - Get user's collections by identifier 44 46 router.get('/user/:identifier', authMiddleware.optionalAuth(), (req, res) => 45 47 getUserCollectionsController.execute(req, res), 48 + ); 49 + 50 + // GET /api/collections/contributed/:identifier - Get open collections where user contributed 51 + router.get( 52 + '/contributed/:identifier', 53 + authMiddleware.optionalAuth(), 54 + (req, res) => getOpenCollectionsWithContributorController.execute(req, res), 46 55 ); 47 56 48 57 // GET /api/collections/at/:handle/:recordKey - Get collection by AT URI
+3
src/modules/cards/infrastructure/http/routes/index.ts
··· 24 24 import { GetMyCollectionsController } from '../controllers/GetMyCollectionsController'; 25 25 import { GetUserCollectionsController } from '../controllers/GetUserCollectionsController'; 26 26 import { SearchCollectionsController } from '../controllers/SearchCollectionsController'; 27 + import { GetOpenCollectionsWithContributorController } from '../controllers/GetOpenCollectionsWithContributorController'; 27 28 import { GetCollectionPageByAtUriController } from '../controllers/GetCollectionPageByAtUriController'; 28 29 import { GetCollectionsForUrlController } from '../controllers/GetCollectionsForUrlController'; 29 30 ··· 55 56 getCollectionsController: GetUserCollectionsController, 56 57 getCollectionsForUrlController: GetCollectionsForUrlController, 57 58 searchCollectionsController: SearchCollectionsController, 59 + getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController, 58 60 ): Router { 59 61 const router = Router(); 60 62 ··· 95 97 getCollectionPageByAtUriController, 96 98 getCollectionsForUrlController, 97 99 searchCollectionsController, 100 + getOpenCollectionsWithContributorController, 98 101 ), 99 102 ); 100 103
+125
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 21 21 CollectionForUrlRawDTO, 22 22 CollectionForUrlQueryOptions, 23 23 SearchCollectionsOptions, 24 + GetOpenCollectionsWithContributorOptions, 24 25 } from '../../domain/ICollectionQueryRepository'; 25 26 import { collections, collectionCards } from './schema/collection.sql'; 26 27 import { publishedRecords } from './schema/publishedRecord.sql'; ··· 64 65 id: collections.id, 65 66 name: collections.name, 66 67 description: collections.description, 68 + accessType: collections.accessType, 67 69 createdAt: collections.createdAt, 68 70 updatedAt: collections.updatedAt, 69 71 authorId: collections.authorId, ··· 106 108 uri: raw.uri, 107 109 name: raw.name, 108 110 description: raw.description, 111 + accessType: raw.accessType, 109 112 createdAt: raw.createdAt, 110 113 updatedAt: raw.updatedAt, 111 114 authorId: raw.authorId, ··· 203 206 id: collections.id, 204 207 name: collections.name, 205 208 description: collections.description, 209 + accessType: collections.accessType, 206 210 authorId: collections.authorId, 207 211 uri: publishedRecords.uri, 208 212 createdAt: collections.createdAt, ··· 246 250 uri: result.uri || undefined, 247 251 name: result.name, 248 252 description: result.description || undefined, 253 + accessType: result.accessType, 249 254 authorId: result.authorId, 250 255 })); 251 256 ··· 320 325 id: collections.id, 321 326 name: collections.name, 322 327 description: collections.description, 328 + accessType: collections.accessType, 323 329 createdAt: collections.createdAt, 324 330 updatedAt: collections.updatedAt, 325 331 authorId: collections.authorId, ··· 354 360 uri: raw.uri, 355 361 name: raw.name, 356 362 description: raw.description, 363 + accessType: raw.accessType, 357 364 createdAt: raw.createdAt, 358 365 updatedAt: raw.updatedAt, 359 366 authorId: raw.authorId, ··· 368 375 }; 369 376 } catch (error) { 370 377 console.error('Error in searchCollections:', error); 378 + throw error; 379 + } 380 + } 381 + 382 + async getOpenCollectionsWithContributor( 383 + options: GetOpenCollectionsWithContributorOptions, 384 + ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> { 385 + try { 386 + const { contributorId, page, limit, sortBy, sortOrder } = options; 387 + const offset = (page - 1) * limit; 388 + 389 + // Build the sort order 390 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 391 + 392 + // Get collections where: 393 + // 1. User has added cards (via collection_cards.addedBy) 394 + // 2. User is NOT the author (collections.authorId != contributorId) 395 + // 3. Collection is OPEN (collections.accessType = 'OPEN') 396 + // Sort by most recent contribution (addedAt DESC) as primary sort 397 + 398 + const collectionsQuery = this.db 399 + .selectDistinct({ 400 + id: collections.id, 401 + name: collections.name, 402 + description: collections.description, 403 + accessType: collections.accessType, 404 + createdAt: collections.createdAt, 405 + updatedAt: collections.updatedAt, 406 + authorId: collections.authorId, 407 + cardCount: collections.cardCount, 408 + uri: publishedRecords.uri, 409 + // Get the most recent contribution date for sorting 410 + lastContributionDate: sql<Date>`MAX(${collectionCards.addedAt})`.as( 411 + 'last_contribution_date', 412 + ), 413 + }) 414 + .from(collections) 415 + .leftJoin( 416 + publishedRecords, 417 + eq(collections.publishedRecordId, publishedRecords.id), 418 + ) 419 + .innerJoin( 420 + collectionCards, 421 + eq(collections.id, collectionCards.collectionId), 422 + ) 423 + .where( 424 + and( 425 + eq(collectionCards.addedBy, contributorId), 426 + sql`${collections.authorId} != ${contributorId}`, // Not the author 427 + eq(collections.accessType, 'OPEN'), 428 + ), 429 + ) 430 + .groupBy( 431 + collections.id, 432 + collections.name, 433 + collections.description, 434 + collections.accessType, 435 + collections.createdAt, 436 + collections.updatedAt, 437 + collections.authorId, 438 + collections.cardCount, 439 + publishedRecords.uri, 440 + ) 441 + .orderBy( 442 + // Primary sort: by most recent contribution (addedAt DESC) 443 + desc(sql`MAX(${collectionCards.addedAt})`), 444 + // Secondary sort: by the specified field 445 + orderDirection(this.getSortColumn(sortBy)), 446 + ) 447 + .limit(limit) 448 + .offset(offset); 449 + 450 + const collectionsResult = await collectionsQuery; 451 + 452 + // Get total count with same conditions 453 + const totalCountQuery = this.db 454 + .selectDistinct({ 455 + id: collections.id, 456 + }) 457 + .from(collections) 458 + .innerJoin( 459 + collectionCards, 460 + eq(collections.id, collectionCards.collectionId), 461 + ) 462 + .where( 463 + and( 464 + eq(collectionCards.addedBy, contributorId), 465 + sql`${collections.authorId} != ${contributorId}`, 466 + eq(collections.accessType, 'OPEN'), 467 + ), 468 + ); 469 + 470 + const totalCountResult = await totalCountQuery; 471 + const totalCount = totalCountResult.length; 472 + const hasMore = offset + collectionsResult.length < totalCount; 473 + 474 + // Map to DTOs 475 + const items = collectionsResult.map((raw) => 476 + CollectionMapper.toQueryResult({ 477 + id: raw.id, 478 + uri: raw.uri, 479 + name: raw.name, 480 + description: raw.description, 481 + accessType: raw.accessType, 482 + createdAt: raw.createdAt, 483 + updatedAt: raw.updatedAt, 484 + authorId: raw.authorId, 485 + cardCount: raw.cardCount, 486 + }), 487 + ); 488 + 489 + return { 490 + items, 491 + totalCount, 492 + hasMore, 493 + }; 494 + } catch (error) { 495 + console.error('Error in getOpenCollectionsWithContributor:', error); 371 496 throw error; 372 497 } 373 498 }
+2
src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts
··· 91 91 id: string; 92 92 name: string; 93 93 authorId: string; 94 + accessType: string; 94 95 }[]; 95 96 note?: { 96 97 id: string; ··· 493 494 id: string; 494 495 name: string; 495 496 authorId: string; 497 + accessType: string; 496 498 }[]; 497 499 note?: { 498 500 id: string;
+2
src/modules/cards/infrastructure/repositories/mappers/CollectionMapper.ts
··· 34 34 uri: string | null; 35 35 name: string; 36 36 description?: string | null; 37 + accessType: string; 37 38 createdAt: Date; 38 39 updatedAt: Date; 39 40 authorId: string; ··· 44 45 uri: raw.uri || undefined, 45 46 name: raw.name, 46 47 description: raw.description || undefined, 48 + accessType: raw.accessType, 47 49 createdAt: raw.createdAt, 48 50 updatedAt: raw.updatedAt, 49 51 authorId: raw.authorId,
+6
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 150 150 collectionId: collections.id, 151 151 collectionName: collections.name, 152 152 authorId: collections.authorId, 153 + accessType: collections.accessType, 153 154 }) 154 155 .from(collectionCards) 155 156 .innerJoin( ··· 219 220 id: c.collectionId, 220 221 name: c.collectionName, 221 222 authorId: c.authorId, 223 + accessType: c.accessType, 222 224 })); 223 225 224 226 // Find note for this card ··· 305 307 collectionId: collections.id, 306 308 collectionName: collections.name, 307 309 authorId: collections.authorId, 310 + accessType: collections.accessType, 308 311 }) 309 312 .from(collectionCards) 310 313 .innerJoin( ··· 401 404 id: c.collectionId, 402 405 name: c.collectionName, 403 406 authorId: c.authorId, 407 + accessType: c.accessType, 404 408 })); 405 409 406 410 // Find note for this card ··· 498 502 collectionId: collections.id, 499 503 collectionName: collections.name, 500 504 authorId: collections.authorId, 505 + accessType: collections.accessType, 501 506 }) 502 507 .from(collectionCards) 503 508 .innerJoin( ··· 578 583 id: coll.collectionId, 579 584 name: coll.collectionName, 580 585 authorId: coll.authorId, 586 + accessType: coll.accessType, 581 587 })), 582 588 note: note 583 589 ? {
+5
src/modules/cards/infrastructure/repositories/schema/collection.sql.ts
··· 83 83 cardCollectionIdx: index('idx_collection_cards_card_collection').on( 84 84 table.cardId, 85 85 ), 86 + // Index for finding collections where user contributed - sorted by contribution time 87 + addedByAddedAtIdx: index('idx_collection_cards_added_by_added_at').on( 88 + table.addedBy, 89 + table.addedAt.desc(), 90 + ), 86 91 }; 87 92 }, 88 93 );
+192 -3
src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts
··· 318 318 }); 319 319 320 320 describe('Collection handling', () => { 321 - it('should add URL card to specified collections', async () => { 322 - // Create a test collection 321 + it('should add URL card to open collections', async () => { 322 + // Create an open test collection 323 323 const collection = new CollectionBuilder() 324 324 .withAuthorId(curatorId.value) 325 - .withName('Test Collection') 325 + .withName('Open Test Collection') 326 + .withAccessType('OPEN') 326 327 .build(); 327 328 328 329 if (collection instanceof Error) { ··· 365 366 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 366 367 }); 367 368 369 + it('should add URL card to closed collections when user is the author', async () => { 370 + // Create a closed test collection owned by the curator 371 + const collection = new CollectionBuilder() 372 + .withAuthorId(curatorId.value) 373 + .withName('Closed Test Collection') 374 + .withAccessType('CLOSED') 375 + .build(); 376 + 377 + if (collection instanceof Error) { 378 + throw new Error(`Failed to create collection: ${collection.message}`); 379 + } 380 + 381 + await collectionRepository.save(collection); 382 + 383 + const request = { 384 + url: 'https://example.com/article', 385 + collectionIds: [collection.collectionId.getStringValue()], 386 + curatorId: curatorId.value, 387 + }; 388 + 389 + const result = await useCase.execute(request); 390 + 391 + expect(result.isOk()).toBe(true); 392 + 393 + // Verify collection link was published 394 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 395 + collection.collectionId.getStringValue(), 396 + ); 397 + expect(publishedLinks).toHaveLength(1); 398 + }); 399 + 400 + it('should add URL card to closed collections when user is a collaborator', async () => { 401 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 402 + 403 + // Create a closed test collection with curator as collaborator 404 + const collection = new CollectionBuilder() 405 + .withAuthorId(otherCuratorId.value) 406 + .withName('Closed Collaborative Collection') 407 + .withAccessType('CLOSED') 408 + .withCollaborators([curatorId.value]) 409 + .build(); 410 + 411 + if (collection instanceof Error) { 412 + throw new Error(`Failed to create collection: ${collection.message}`); 413 + } 414 + 415 + await collectionRepository.save(collection); 416 + 417 + const request = { 418 + url: 'https://example.com/article', 419 + collectionIds: [collection.collectionId.getStringValue()], 420 + curatorId: curatorId.value, 421 + }; 422 + 423 + const result = await useCase.execute(request); 424 + 425 + expect(result.isOk()).toBe(true); 426 + 427 + // Verify collection link was published 428 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 429 + collection.collectionId.getStringValue(), 430 + ); 431 + expect(publishedLinks).toHaveLength(1); 432 + }); 433 + 434 + it('should fail to add URL card to closed collections when user lacks permission', async () => { 435 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 436 + 437 + // Create a closed test collection owned by someone else 438 + const collection = new CollectionBuilder() 439 + .withAuthorId(otherCuratorId.value) 440 + .withName('Private Closed Collection') 441 + .withAccessType('CLOSED') 442 + .build(); 443 + 444 + if (collection instanceof Error) { 445 + throw new Error(`Failed to create collection: ${collection.message}`); 446 + } 447 + 448 + await collectionRepository.save(collection); 449 + 450 + const request = { 451 + url: 'https://example.com/article', 452 + collectionIds: [collection.collectionId.getStringValue()], 453 + curatorId: curatorId.value, 454 + }; 455 + 456 + const result = await useCase.execute(request); 457 + 458 + expect(result.isErr()).toBe(true); 459 + if (result.isErr()) { 460 + expect(result.error.message).toContain('does not have permission'); 461 + } 462 + 463 + // Verify no collection link was published 464 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 465 + collection.collectionId.getStringValue(), 466 + ); 467 + expect(publishedLinks).toHaveLength(0); 468 + }); 469 + 470 + it('should add URL card to open collections owned by other users', async () => { 471 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 472 + 473 + // Create an open test collection owned by someone else 474 + const collection = new CollectionBuilder() 475 + .withAuthorId(otherCuratorId.value) 476 + .withName('Public Open Collection') 477 + .withAccessType('OPEN') 478 + .build(); 479 + 480 + if (collection instanceof Error) { 481 + throw new Error(`Failed to create collection: ${collection.message}`); 482 + } 483 + 484 + await collectionRepository.save(collection); 485 + 486 + const request = { 487 + url: 'https://example.com/article', 488 + collectionIds: [collection.collectionId.getStringValue()], 489 + curatorId: curatorId.value, 490 + }; 491 + 492 + const result = await useCase.execute(request); 493 + 494 + expect(result.isOk()).toBe(true); 495 + 496 + // Verify collection link was published 497 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 498 + collection.collectionId.getStringValue(), 499 + ); 500 + expect(publishedLinks).toHaveLength(1); 501 + }); 502 + 368 503 it('should add URL card to collections with viaCardId', async () => { 369 504 // Create a test collection 370 505 const collection = new CollectionBuilder() ··· 520 655 if (result.isErr()) { 521 656 expect(result.error.message).toContain('Collection not found'); 522 657 } 658 + }); 659 + 660 + it('should handle mixed collection permissions correctly', async () => { 661 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 662 + 663 + // Create an open collection and a closed collection without permission 664 + const openCollection = new CollectionBuilder() 665 + .withAuthorId(otherCuratorId.value) 666 + .withName('Open Collection') 667 + .withAccessType('OPEN') 668 + .build(); 669 + 670 + const closedCollection = new CollectionBuilder() 671 + .withAuthorId(otherCuratorId.value) 672 + .withName('Closed Collection') 673 + .withAccessType('CLOSED') 674 + .build(); 675 + 676 + if ( 677 + openCollection instanceof Error || 678 + closedCollection instanceof Error 679 + ) { 680 + throw new Error('Failed to create collections'); 681 + } 682 + 683 + await collectionRepository.save(openCollection); 684 + await collectionRepository.save(closedCollection); 685 + 686 + const request = { 687 + url: 'https://example.com/article', 688 + collectionIds: [ 689 + openCollection.collectionId.getStringValue(), 690 + closedCollection.collectionId.getStringValue(), 691 + ], 692 + curatorId: curatorId.value, 693 + }; 694 + 695 + const result = await useCase.execute(request); 696 + 697 + expect(result.isErr()).toBe(true); 698 + if (result.isErr()) { 699 + expect(result.error.message).toContain('does not have permission'); 700 + } 701 + 702 + const openPublishedLinks = 703 + collectionPublisher.getPublishedLinksForCollection( 704 + openCollection.collectionId.getStringValue(), 705 + ); 706 + const closedPublishedLinks = 707 + collectionPublisher.getPublishedLinksForCollection( 708 + closedCollection.collectionId.getStringValue(), 709 + ); 710 + expect(openPublishedLinks).toHaveLength(1); 711 + expect(closedPublishedLinks).toHaveLength(0); 523 712 }); 524 713 }); 525 714
+1
src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts
··· 780 780 .fn() 781 781 .mockRejectedValue(new Error('Database error')), 782 782 searchCollections: jest.fn(), 783 + getOpenCollectionsWithContributor: jest.fn(), 783 784 }; 784 785 785 786 const errorUseCase = new GetCollectionsForUrlUseCase(
+1
src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts
··· 591 591 .mockRejectedValue(new Error('Collection query error')), 592 592 getCollectionsWithUrl: jest.fn(), 593 593 searchCollections: jest.fn(), 594 + getOpenCollectionsWithContributor: jest.fn(), 594 595 }; 595 596 596 597 const errorUseCase = new GetUrlStatusForMyLibraryUseCase(
+307 -7
src/modules/cards/tests/application/RemoveCardFromCollectionUseCase.test.ts
··· 2 2 import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3 3 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 4 4 import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 5 + import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 5 6 import { CardCollectionService } from '../../domain/services/CardCollectionService'; 6 7 import { CuratorId } from '../../domain/value-objects/CuratorId'; 7 8 import { CardBuilder } from '../utils/builders/CardBuilder'; ··· 13 14 let cardRepository: InMemoryCardRepository; 14 15 let collectionRepository: InMemoryCollectionRepository; 15 16 let collectionPublisher: FakeCollectionPublisher; 17 + let eventPublisher: FakeEventPublisher; 16 18 let cardCollectionService: CardCollectionService; 17 19 let curatorId: CuratorId; 18 20 let otherCuratorId: CuratorId; ··· 21 23 cardRepository = InMemoryCardRepository.getInstance(); 22 24 collectionRepository = InMemoryCollectionRepository.getInstance(); 23 25 collectionPublisher = new FakeCollectionPublisher(); 26 + eventPublisher = new FakeEventPublisher(); 24 27 cardCollectionService = new CardCollectionService( 25 28 collectionRepository, 26 29 collectionPublisher, ··· 30 33 useCase = new RemoveCardFromCollectionUseCase( 31 34 cardRepository, 32 35 cardCollectionService, 36 + eventPublisher, 33 37 ); 34 38 35 39 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); ··· 40 44 cardRepository.clear(); 41 45 collectionRepository.clear(); 42 46 collectionPublisher.clear(); 47 + eventPublisher.clear(); 43 48 }); 44 49 45 50 const createCard = async (type: CardTypeEnum = CardTypeEnum.URL) => { ··· 214 219 } 215 220 }); 216 221 217 - it('should allow removal from open collection by any user', async () => { 222 + it('should allow removal from open collection by collection author (can remove any card)', async () => { 223 + const card = await createCard(); 224 + const collection = new CollectionBuilder() 225 + .withAuthorId(curatorId.value) 226 + .withName('Open Collection') 227 + .withAccessType('OPEN') 228 + .withPublished(true) 229 + .build(); 230 + 231 + if (collection instanceof Error) { 232 + throw new Error(`Failed to create collection: ${collection.message}`); 233 + } 234 + 235 + await collectionRepository.save(collection); 236 + 237 + // Add card to collection (as collection owner) 238 + await addCardToCollection(card, collection, curatorId); 239 + 240 + const request = { 241 + cardId: card.cardId.getStringValue(), 242 + collectionIds: [collection.collectionId.getStringValue()], 243 + curatorId: curatorId.value, 244 + }; 245 + 246 + const result = await useCase.execute(request); 247 + 248 + expect(result.isOk()).toBe(true); 249 + 250 + // Verify card was removed from collection 251 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 252 + collection.collectionId.getStringValue(), 253 + ); 254 + expect(removedLinks).toHaveLength(1); 255 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 256 + }); 257 + 258 + it('should fail when non-author tries to remove another users card from open collection', async () => { 218 259 const card = await createCard(); 219 260 const collection = new CollectionBuilder() 220 261 .withAuthorId(otherCuratorId.value) ··· 235 276 const request = { 236 277 cardId: card.cardId.getStringValue(), 237 278 collectionIds: [collection.collectionId.getStringValue()], 279 + curatorId: curatorId.value, // Non-author trying to remove owner's card 280 + }; 281 + 282 + const result = await useCase.execute(request); 283 + 284 + // Should fail - non-author cannot remove someone else's card 285 + expect(result.isErr()).toBe(true); 286 + if (result.isErr()) { 287 + expect(result.error.message).toContain('does not have permission'); 288 + } 289 + 290 + // Verify card was NOT removed 291 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 292 + collection.collectionId.getStringValue(), 293 + ); 294 + expect(removedLinks).toHaveLength(0); 295 + }); 296 + 297 + it('should allow collection author to remove any card from closed collection', async () => { 298 + const card = await createCard(); 299 + const collection = new CollectionBuilder() 300 + .withAuthorId(curatorId.value) 301 + .withName('Closed Collection') 302 + .withAccessType('CLOSED') 303 + .withPublished(true) 304 + .build(); 305 + 306 + if (collection instanceof Error) { 307 + throw new Error(`Failed to create collection: ${collection.message}`); 308 + } 309 + 310 + await collectionRepository.save(collection); 311 + 312 + await addCardToCollection(card, collection, curatorId); 313 + 314 + const request = { 315 + cardId: card.cardId.getStringValue(), 316 + collectionIds: [collection.collectionId.getStringValue()], 238 317 curatorId: curatorId.value, 239 318 }; 240 319 241 320 const result = await useCase.execute(request); 242 321 243 322 expect(result.isOk()).toBe(true); 323 + 324 + // Verify card was removed from collection 325 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 326 + collection.collectionId.getStringValue(), 327 + ); 328 + expect(removedLinks).toHaveLength(1); 329 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 244 330 }); 245 331 246 - it('should allow collection author to remove any card', async () => { 332 + it.skip('should allow collaborator to remove cards from closed collection', async () => { 247 333 const card = await createCard(); 248 - const collection = await createCollection( 249 - curatorId, 250 - "Author's Collection", 251 - ); 334 + const collection = new CollectionBuilder() 335 + .withAuthorId(otherCuratorId.value) 336 + .withName('Closed Collaborative Collection') 337 + .withAccessType('CLOSED') 338 + .withCollaborators([curatorId.value]) 339 + .withPublished(true) 340 + .build(); 341 + 342 + if (collection instanceof Error) { 343 + throw new Error(`Failed to create collection: ${collection.message}`); 344 + } 252 345 253 - await addCardToCollection(card, collection, curatorId); 346 + await collectionRepository.save(collection); 347 + 348 + // Add card to collection first (as collection owner) 349 + await addCardToCollection(card, collection, otherCuratorId); 254 350 255 351 const request = { 256 352 cardId: card.cardId.getStringValue(), ··· 261 357 const result = await useCase.execute(request); 262 358 263 359 expect(result.isOk()).toBe(true); 360 + 361 + // Verify card was removed from collection 362 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 363 + collection.collectionId.getStringValue(), 364 + ); 365 + expect(removedLinks).toHaveLength(1); 366 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 367 + }); 368 + 369 + it.skip('should handle mixed collection permissions when removing cards', async () => { 370 + const card = await createCard(); 371 + 372 + // Create an open collection and a closed collection without permission 373 + const openCollection = new CollectionBuilder() 374 + .withAuthorId(otherCuratorId.value) 375 + .withName('Open Collection') 376 + .withAccessType('OPEN') 377 + .withPublished(true) 378 + .build(); 379 + 380 + const closedCollection = new CollectionBuilder() 381 + .withAuthorId(otherCuratorId.value) 382 + .withName('Closed Collection') 383 + .withAccessType('CLOSED') 384 + .withPublished(true) 385 + .build(); 386 + 387 + if ( 388 + openCollection instanceof Error || 389 + closedCollection instanceof Error 390 + ) { 391 + throw new Error('Failed to create collections'); 392 + } 393 + 394 + await collectionRepository.save(openCollection); 395 + await collectionRepository.save(closedCollection); 396 + 397 + // Add card to both collections (as collection owner) 398 + await addCardToCollection(card, openCollection, otherCuratorId); 399 + await addCardToCollection(card, closedCollection, otherCuratorId); 400 + 401 + const request = { 402 + cardId: card.cardId.getStringValue(), 403 + collectionIds: [ 404 + openCollection.collectionId.getStringValue(), 405 + closedCollection.collectionId.getStringValue(), 406 + ], 407 + curatorId: curatorId.value, 408 + }; 409 + 410 + const result = await useCase.execute(request); 411 + 412 + expect(result.isErr()).toBe(true); 413 + if (result.isErr()) { 414 + expect(result.error.message).toContain('does not have permission'); 415 + } 416 + 417 + // Note: The removal is not transactional - open collection removal succeeds 418 + // before the closed collection permission check fails 419 + const openRemovedLinks = collectionPublisher.getRemovedLinksForCollection( 420 + openCollection.collectionId.getStringValue(), 421 + ); 422 + const closedRemovedLinks = 423 + collectionPublisher.getRemovedLinksForCollection( 424 + closedCollection.collectionId.getStringValue(), 425 + ); 426 + // Open collection removal succeeds (anyone can remove from open collections) 427 + expect(openRemovedLinks).toHaveLength(1); 428 + // Closed collection removal fails (no permission) 429 + expect(closedRemovedLinks).toHaveLength(0); 264 430 }); 265 431 }); 266 432 ··· 367 533 // No removal operations should have occurred 368 534 const allRemovedLinks = collectionPublisher.getAllRemovedLinks(); 369 535 expect(allRemovedLinks).toHaveLength(0); 536 + }); 537 + }); 538 + 539 + describe('CollectionLinkRemoval for open collections', () => { 540 + it('should publish removal record when collection owner removes a card added by contributor', async () => { 541 + const card = await createCard(); 542 + const collection = new CollectionBuilder() 543 + .withAuthorId(curatorId.value) 544 + .withName('Owner Collection') 545 + .withAccessType('OPEN') 546 + .withPublished(true) 547 + .build(); 548 + 549 + if (collection instanceof Error) { 550 + throw new Error(`Failed to create collection: ${collection.message}`); 551 + } 552 + 553 + await collectionRepository.save(collection); 554 + 555 + // Contributor adds card to owner's collection 556 + await addCardToCollection(card, collection, otherCuratorId); 557 + 558 + // Owner removes the card added by contributor 559 + const request = { 560 + cardId: card.cardId.getStringValue(), 561 + collectionIds: [collection.collectionId.getStringValue()], 562 + curatorId: curatorId.value, // Owner is removing 563 + }; 564 + 565 + const result = await useCase.execute(request); 566 + 567 + expect(result.isOk()).toBe(true); 568 + 569 + // Verify a removal record was published (not unpublished) 570 + const publishedRemovals = 571 + collectionPublisher.getPublishedRemovalsForCollection( 572 + collection.collectionId.getStringValue(), 573 + ); 574 + expect(publishedRemovals).toHaveLength(1); 575 + expect(publishedRemovals[0]?.cardId).toBe(card.cardId.getStringValue()); 576 + 577 + // Verify the link was NOT unpublished (since owner can't delete contributor's record) 578 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 579 + collection.collectionId.getStringValue(), 580 + ); 581 + expect(removedLinks).toHaveLength(0); 582 + }); 583 + 584 + it('should unpublish link when contributor removes their own card from collection', async () => { 585 + const card = await createCard(); 586 + const collection = new CollectionBuilder() 587 + .withAuthorId(curatorId.value) 588 + .withName('Owner Collection') 589 + .withAccessType('OPEN') 590 + .withPublished(true) 591 + .build(); 592 + 593 + if (collection instanceof Error) { 594 + throw new Error(`Failed to create collection: ${collection.message}`); 595 + } 596 + 597 + await collectionRepository.save(collection); 598 + 599 + // Contributor adds card to owner's collection 600 + await addCardToCollection(card, collection, otherCuratorId); 601 + 602 + // Contributor removes their own card 603 + const request = { 604 + cardId: card.cardId.getStringValue(), 605 + collectionIds: [collection.collectionId.getStringValue()], 606 + curatorId: otherCuratorId.value, // Contributor is removing their own card 607 + }; 608 + 609 + const result = await useCase.execute(request); 610 + 611 + expect(result.isOk()).toBe(true); 612 + 613 + // Verify the link was unpublished (contributor deletes their own record) 614 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 615 + collection.collectionId.getStringValue(), 616 + ); 617 + expect(removedLinks).toHaveLength(1); 618 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 619 + 620 + // Verify NO removal record was published 621 + const publishedRemovals = 622 + collectionPublisher.getPublishedRemovalsForCollection( 623 + collection.collectionId.getStringValue(), 624 + ); 625 + expect(publishedRemovals).toHaveLength(0); 626 + }); 627 + 628 + it('should unpublish link when owner removes their own card from their collection', async () => { 629 + const card = await createCard(); 630 + const collection = new CollectionBuilder() 631 + .withAuthorId(curatorId.value) 632 + .withName('Owner Collection') 633 + .withAccessType('OPEN') 634 + .withPublished(true) 635 + .build(); 636 + 637 + if (collection instanceof Error) { 638 + throw new Error(`Failed to create collection: ${collection.message}`); 639 + } 640 + 641 + await collectionRepository.save(collection); 642 + 643 + // Owner adds card to their own collection 644 + await addCardToCollection(card, collection, curatorId); 645 + 646 + // Owner removes their own card 647 + const request = { 648 + cardId: card.cardId.getStringValue(), 649 + collectionIds: [collection.collectionId.getStringValue()], 650 + curatorId: curatorId.value, // Owner removing their own card 651 + }; 652 + 653 + const result = await useCase.execute(request); 654 + 655 + expect(result.isOk()).toBe(true); 656 + 657 + // Verify the link was unpublished (owner deletes their own record) 658 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 659 + collection.collectionId.getStringValue(), 660 + ); 661 + expect(removedLinks).toHaveLength(1); 662 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 663 + 664 + // Verify NO removal record was published 665 + const publishedRemovals = 666 + collectionPublisher.getPublishedRemovalsForCollection( 667 + collection.collectionId.getStringValue(), 668 + ); 669 + expect(publishedRemovals).toHaveLength(0); 370 670 }); 371 671 }); 372 672
+281 -7
src/modules/cards/tests/application/UpdateUrlCardAssociationsUseCase.test.ts
··· 11 11 import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 12 12 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 13 13 import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 14 + import { CardId } from '../../domain/value-objects/CardId'; 14 15 15 16 describe('UpdateUrlCardAssociationsUseCase', () => { 16 17 let useCase: UpdateUrlCardAssociationsUseCase; ··· 159 160 }); 160 161 161 162 describe('Collection management', () => { 162 - it('should add URL card to collections', async () => { 163 + it('should add URL card to open collections', async () => { 163 164 const url = 'https://example.com/article'; 164 165 165 - // Create collections 166 + // Create open collections 166 167 const collection1 = new CollectionBuilder() 167 168 .withAuthorId(curatorId.value) 168 - .withName('Collection 1') 169 + .withName('Open Collection 1') 170 + .withAccessType('OPEN') 169 171 .build(); 170 172 const collection2 = new CollectionBuilder() 171 173 .withAuthorId(curatorId.value) 172 - .withName('Collection 2') 174 + .withName('Open Collection 2') 175 + .withAccessType('OPEN') 173 176 .build(); 174 177 175 178 if (collection1 instanceof Error || collection2 instanceof Error) { ··· 210 213 ); 211 214 }); 212 215 216 + it('should add URL card to closed collections when user is the author', async () => { 217 + const url = 'https://example.com/article'; 218 + 219 + // Create closed collection owned by the curator 220 + const collection = new CollectionBuilder() 221 + .withAuthorId(curatorId.value) 222 + .withName('Closed Collection') 223 + .withAccessType('CLOSED') 224 + .build(); 225 + 226 + if (collection instanceof Error) { 227 + throw new Error('Failed to create collection'); 228 + } 229 + 230 + await collectionRepository.save(collection); 231 + 232 + // Add URL to library 233 + const addResult = await addUrlToLibraryUseCase.execute({ 234 + url, 235 + curatorId: curatorId.value, 236 + }); 237 + expect(addResult.isOk()).toBe(true); 238 + const urlCardId = addResult.unwrap().urlCardId; 239 + 240 + // Add to collection 241 + const updateRequest = { 242 + cardId: urlCardId, 243 + curatorId: curatorId.value, 244 + addToCollections: [collection.collectionId.getStringValue()], 245 + }; 246 + 247 + const result = await useCase.execute(updateRequest); 248 + 249 + expect(result.isOk()).toBe(true); 250 + const response = result.unwrap(); 251 + expect(response.addedToCollections).toHaveLength(1); 252 + expect(response.addedToCollections).toContain( 253 + collection.collectionId.getStringValue(), 254 + ); 255 + }); 256 + 257 + it('should fail to add URL card to closed collections when user lacks permission', async () => { 258 + const url = 'https://example.com/article'; 259 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 260 + 261 + // Create closed collection owned by someone else 262 + const collection = new CollectionBuilder() 263 + .withAuthorId(otherCuratorId.value) 264 + .withName('Private Closed Collection') 265 + .withAccessType('CLOSED') 266 + .build(); 267 + 268 + if (collection instanceof Error) { 269 + throw new Error('Failed to create collection'); 270 + } 271 + 272 + await collectionRepository.save(collection); 273 + 274 + // Add URL to library 275 + const addResult = await addUrlToLibraryUseCase.execute({ 276 + url, 277 + curatorId: curatorId.value, 278 + }); 279 + expect(addResult.isOk()).toBe(true); 280 + const urlCardId = addResult.unwrap().urlCardId; 281 + 282 + // Try to add to collection 283 + const updateRequest = { 284 + cardId: urlCardId, 285 + curatorId: curatorId.value, 286 + addToCollections: [collection.collectionId.getStringValue()], 287 + }; 288 + 289 + const result = await useCase.execute(updateRequest); 290 + 291 + expect(result.isErr()).toBe(true); 292 + if (result.isErr()) { 293 + expect(result.error.message).toContain('does not have permission'); 294 + } 295 + }); 296 + 297 + it('should add URL card to open collections owned by other users', async () => { 298 + const url = 'https://example.com/article'; 299 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 300 + 301 + // Create open collection owned by someone else 302 + const collection = new CollectionBuilder() 303 + .withAuthorId(otherCuratorId.value) 304 + .withName('Public Open Collection') 305 + .withAccessType('OPEN') 306 + .build(); 307 + 308 + if (collection instanceof Error) { 309 + throw new Error('Failed to create collection'); 310 + } 311 + 312 + await collectionRepository.save(collection); 313 + 314 + // Add URL to library 315 + const addResult = await addUrlToLibraryUseCase.execute({ 316 + url, 317 + curatorId: curatorId.value, 318 + }); 319 + expect(addResult.isOk()).toBe(true); 320 + const urlCardId = addResult.unwrap().urlCardId; 321 + 322 + // Add to collection 323 + const updateRequest = { 324 + cardId: urlCardId, 325 + curatorId: curatorId.value, 326 + addToCollections: [collection.collectionId.getStringValue()], 327 + }; 328 + 329 + const result = await useCase.execute(updateRequest); 330 + 331 + expect(result.isOk()).toBe(true); 332 + const response = result.unwrap(); 333 + expect(response.addedToCollections).toHaveLength(1); 334 + expect(response.addedToCollections).toContain( 335 + collection.collectionId.getStringValue(), 336 + ); 337 + }); 338 + 213 339 it('should add URL card to collections with viaCardId', async () => { 214 340 const url = 'https://example.com/article'; 215 341 ··· 269 395 expect(publishedLink?.cardId).toBe(urlCardId); 270 396 }); 271 397 272 - it('should remove URL card from collections', async () => { 398 + it('should remove URL card from open collections', async () => { 273 399 const url = 'https://example.com/article'; 274 400 275 - // Create collection 401 + // Create open collection 276 402 const collection = new CollectionBuilder() 277 403 .withAuthorId(curatorId.value) 278 - .withName('Test Collection') 404 + .withName('Open Test Collection') 405 + .withAccessType('OPEN') 279 406 .build(); 280 407 281 408 if (collection instanceof Error) { ··· 294 421 const urlCardId = addResult.unwrap().urlCardId; 295 422 296 423 // Remove from collection 424 + const updateRequest = { 425 + cardId: urlCardId, 426 + curatorId: curatorId.value, 427 + removeFromCollections: [collection.collectionId.getStringValue()], 428 + }; 429 + 430 + const result = await useCase.execute(updateRequest); 431 + 432 + expect(result.isOk()).toBe(true); 433 + const response = result.unwrap(); 434 + expect(response.removedFromCollections).toHaveLength(1); 435 + expect(response.removedFromCollections).toContain( 436 + collection.collectionId.getStringValue(), 437 + ); 438 + }); 439 + 440 + it('should remove URL card from closed collections when user is the author', async () => { 441 + const url = 'https://example.com/article'; 442 + 443 + // Create closed collection owned by the curator 444 + const collection = new CollectionBuilder() 445 + .withAuthorId(curatorId.value) 446 + .withName('Closed Test Collection') 447 + .withAccessType('CLOSED') 448 + .build(); 449 + 450 + if (collection instanceof Error) { 451 + throw new Error('Failed to create collection'); 452 + } 453 + 454 + await collectionRepository.save(collection); 455 + 456 + // Add URL to library and collection 457 + const addResult = await addUrlToLibraryUseCase.execute({ 458 + url, 459 + collectionIds: [collection.collectionId.getStringValue()], 460 + curatorId: curatorId.value, 461 + }); 462 + expect(addResult.isOk()).toBe(true); 463 + const urlCardId = addResult.unwrap().urlCardId; 464 + 465 + // Remove from collection 466 + const updateRequest = { 467 + cardId: urlCardId, 468 + curatorId: curatorId.value, 469 + removeFromCollections: [collection.collectionId.getStringValue()], 470 + }; 471 + 472 + const result = await useCase.execute(updateRequest); 473 + 474 + expect(result.isOk()).toBe(true); 475 + const response = result.unwrap(); 476 + expect(response.removedFromCollections).toHaveLength(1); 477 + expect(response.removedFromCollections).toContain( 478 + collection.collectionId.getStringValue(), 479 + ); 480 + }); 481 + 482 + it('should fail to remove URL card from closed collections when user lacks permission', async () => { 483 + const url = 'https://example.com/article'; 484 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 485 + 486 + // Create closed collection owned by someone else 487 + const collection = new CollectionBuilder() 488 + .withAuthorId(otherCuratorId.value) 489 + .withName('Private Closed Collection') 490 + .withAccessType('CLOSED') 491 + .build(); 492 + 493 + if (collection instanceof Error) { 494 + throw new Error('Failed to create collection'); 495 + } 496 + 497 + await collectionRepository.save(collection); 498 + 499 + // Add URL to library first 500 + const addResult = await addUrlToLibraryUseCase.execute({ 501 + url, 502 + curatorId: curatorId.value, 503 + }); 504 + expect(addResult.isOk()).toBe(true); 505 + const urlCardId = addResult.unwrap().urlCardId; 506 + 507 + // Manually add card to collection (as collection owner) to simulate existing state 508 + const cardResult = await cardRepository.findById( 509 + CardId.createFromString(urlCardId).unwrap(), 510 + ); 511 + expect(cardResult.isOk()).toBe(true); 512 + const card = cardResult.unwrap()!; 513 + 514 + const addCardResult = collection.addCard(card.cardId, otherCuratorId); 515 + expect(addCardResult.isOk()).toBe(true); 516 + await collectionRepository.save(collection); 517 + 518 + // Try to remove from collection 519 + const updateRequest = { 520 + cardId: urlCardId, 521 + curatorId: curatorId.value, 522 + removeFromCollections: [collection.collectionId.getStringValue()], 523 + }; 524 + 525 + const result = await useCase.execute(updateRequest); 526 + 527 + expect(result.isErr()).toBe(true); 528 + if (result.isErr()) { 529 + expect(result.error.message).toContain('does not have permission'); 530 + } 531 + }); 532 + 533 + it('should remove URL card from open collections owned by other users', async () => { 534 + const url = 'https://example.com/article'; 535 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 536 + 537 + // Create open collection owned by someone else 538 + const collection = new CollectionBuilder() 539 + .withAuthorId(otherCuratorId.value) 540 + .withName('Public Open Collection') 541 + .withAccessType('OPEN') 542 + .build(); 543 + 544 + if (collection instanceof Error) { 545 + throw new Error('Failed to create collection'); 546 + } 547 + 548 + await collectionRepository.save(collection); 549 + 550 + // Add URL to library first 551 + const addResult = await addUrlToLibraryUseCase.execute({ 552 + url, 553 + curatorId: curatorId.value, 554 + }); 555 + expect(addResult.isOk()).toBe(true); 556 + const urlCardId = addResult.unwrap().urlCardId; 557 + 558 + // Add card to collection as the current user (curatorId) 559 + // In an OPEN collection, users can only remove cards they added themselves 560 + const cardResult = await cardRepository.findById( 561 + CardId.createFromString(urlCardId).unwrap(), 562 + ); 563 + expect(cardResult.isOk()).toBe(true); 564 + const card = cardResult.unwrap()!; 565 + 566 + const addCardResult = collection.addCard(card.cardId, curatorId); 567 + expect(addCardResult.isOk()).toBe(true); 568 + await collectionRepository.save(collection); 569 + 570 + // Remove from collection - should succeed because curatorId added the card 297 571 const updateRequest = { 298 572 cardId: urlCardId, 299 573 curatorId: curatorId.value,
+951
src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getOpenCollectionsWithContributor.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 { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository'; 8 + import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; 9 + import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; 10 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 11 + import { cards } from '../../infrastructure/repositories/schema/card.sql'; 12 + import { 13 + collections, 14 + collectionCards, 15 + } from '../../infrastructure/repositories/schema/collection.sql'; 16 + import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 17 + import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 18 + import { CardBuilder } from '../utils/builders/CardBuilder'; 19 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 20 + import { URL } from '../../domain/value-objects/URL'; 21 + import { createTestSchema } from '../test-utils/createTestSchema'; 22 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 23 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 24 + import { 25 + CollectionSortField, 26 + SortOrder, 27 + } from '../../domain/ICollectionQueryRepository'; 28 + import { CollectionAccessType } from '../../domain/Collection'; 29 + 30 + describe('DrizzleCollectionQueryRepository - getOpenCollectionsWithContributor', () => { 31 + let container: StartedPostgreSqlContainer; 32 + let db: PostgresJsDatabase; 33 + let queryRepository: DrizzleCollectionQueryRepository; 34 + let cardRepository: DrizzleCardRepository; 35 + let collectionRepository: DrizzleCollectionRepository; 36 + 37 + // Test data 38 + let contributor: CuratorId; 39 + let author1: CuratorId; 40 + let author2: CuratorId; 41 + let author3: CuratorId; 42 + 43 + // Setup before all tests 44 + beforeAll(async () => { 45 + // Start PostgreSQL container 46 + container = await new PostgreSqlContainer('postgres:14').start(); 47 + 48 + // Create database connection 49 + const connectionString = container.getConnectionUri(); 50 + process.env.DATABASE_URL = connectionString; 51 + const client = postgres(connectionString); 52 + db = drizzle(client); 53 + 54 + // Create repositories 55 + queryRepository = new DrizzleCollectionQueryRepository(db); 56 + cardRepository = new DrizzleCardRepository(db); 57 + collectionRepository = new DrizzleCollectionRepository(db); 58 + 59 + // Create schema using helper function 60 + await createTestSchema(db); 61 + 62 + // Create test data 63 + contributor = CuratorId.create('did:plc:contributor').unwrap(); 64 + author1 = CuratorId.create('did:plc:author1').unwrap(); 65 + author2 = CuratorId.create('did:plc:author2').unwrap(); 66 + author3 = CuratorId.create('did:plc:author3').unwrap(); 67 + }, 60000); // Increase timeout for container startup 68 + 69 + // Cleanup after all tests 70 + afterAll(async () => { 71 + // Stop container 72 + await container.stop(); 73 + }); 74 + 75 + // Clear data between tests 76 + beforeEach(async () => { 77 + await db.delete(collectionCards); 78 + await db.delete(collections); 79 + await db.delete(libraryMemberships); 80 + await db.delete(cards); 81 + await db.delete(publishedRecords); 82 + }); 83 + 84 + describe('Basic filtering', () => { 85 + it('should return empty result when contributor has not added cards to any collections', async () => { 86 + const result = await queryRepository.getOpenCollectionsWithContributor({ 87 + contributorId: contributor.value, 88 + page: 1, 89 + limit: 10, 90 + sortBy: CollectionSortField.UPDATED_AT, 91 + sortOrder: SortOrder.DESC, 92 + }); 93 + 94 + expect(result.items).toHaveLength(0); 95 + expect(result.totalCount).toBe(0); 96 + expect(result.hasMore).toBe(false); 97 + }); 98 + 99 + it('should return open collections where contributor has added cards', async () => { 100 + // Create cards 101 + const card1 = new CardBuilder() 102 + .withCuratorId(contributor.value) 103 + .withType(CardTypeEnum.NOTE) 104 + .buildOrThrow(); 105 + 106 + card1.addToLibrary(contributor); 107 + await cardRepository.save(card1); 108 + 109 + // Create OPEN collection authored by author1 110 + const collection1 = new CollectionBuilder() 111 + .withAuthorId(author1.value) 112 + .withName('Tech Articles') 113 + .withDescription('Collection of tech articles') 114 + .withAccessType(CollectionAccessType.OPEN) 115 + .buildOrThrow(); 116 + 117 + // Contributor adds their card to author1's collection 118 + collection1.addCard(card1.cardId, contributor); 119 + await collectionRepository.save(collection1); 120 + 121 + const result = await queryRepository.getOpenCollectionsWithContributor({ 122 + contributorId: contributor.value, 123 + page: 1, 124 + limit: 10, 125 + sortBy: CollectionSortField.UPDATED_AT, 126 + sortOrder: SortOrder.DESC, 127 + }); 128 + 129 + expect(result.items).toHaveLength(1); 130 + expect(result.totalCount).toBe(1); 131 + expect(result.hasMore).toBe(false); 132 + expect(result.items[0]!.name).toBe('Tech Articles'); 133 + expect(result.items[0]!.authorId).toBe(author1.value); 134 + expect(result.items[0]!.accessType).toBe(CollectionAccessType.OPEN); 135 + }); 136 + 137 + it('should NOT return CLOSED collections even if contributor added cards', async () => { 138 + // Create card 139 + const card = new CardBuilder() 140 + .withCuratorId(contributor.value) 141 + .withType(CardTypeEnum.NOTE) 142 + .buildOrThrow(); 143 + 144 + card.addToLibrary(contributor); 145 + await cardRepository.save(card); 146 + 147 + // Create CLOSED collection 148 + const closedCollection = new CollectionBuilder() 149 + .withAuthorId(author1.value) 150 + .withName('Private Collection') 151 + .withAccessType(CollectionAccessType.CLOSED) 152 + .buildOrThrow(); 153 + 154 + closedCollection.addCard(card.cardId, contributor); 155 + await collectionRepository.save(closedCollection); 156 + 157 + const result = await queryRepository.getOpenCollectionsWithContributor({ 158 + contributorId: contributor.value, 159 + page: 1, 160 + limit: 10, 161 + sortBy: CollectionSortField.UPDATED_AT, 162 + sortOrder: SortOrder.DESC, 163 + }); 164 + 165 + expect(result.items).toHaveLength(0); 166 + expect(result.totalCount).toBe(0); 167 + }); 168 + 169 + it('should NOT return collections where user is the author', async () => { 170 + // Create card 171 + const card = new CardBuilder() 172 + .withCuratorId(contributor.value) 173 + .withType(CardTypeEnum.NOTE) 174 + .buildOrThrow(); 175 + 176 + card.addToLibrary(contributor); 177 + await cardRepository.save(card); 178 + 179 + // Create OPEN collection where contributor is the author 180 + const ownCollection = new CollectionBuilder() 181 + .withAuthorId(contributor.value) 182 + .withName('My Own Collection') 183 + .withAccessType(CollectionAccessType.OPEN) 184 + .buildOrThrow(); 185 + 186 + ownCollection.addCard(card.cardId, contributor); 187 + await collectionRepository.save(ownCollection); 188 + 189 + const result = await queryRepository.getOpenCollectionsWithContributor({ 190 + contributorId: contributor.value, 191 + page: 1, 192 + limit: 10, 193 + sortBy: CollectionSortField.UPDATED_AT, 194 + sortOrder: SortOrder.DESC, 195 + }); 196 + 197 + expect(result.items).toHaveLength(0); 198 + expect(result.totalCount).toBe(0); 199 + }); 200 + 201 + it('should return collections from multiple authors where contributor added cards', async () => { 202 + // Create cards 203 + const card1 = new CardBuilder() 204 + .withCuratorId(contributor.value) 205 + .withType(CardTypeEnum.NOTE) 206 + .buildOrThrow(); 207 + 208 + const card2 = new CardBuilder() 209 + .withCuratorId(contributor.value) 210 + .withType(CardTypeEnum.NOTE) 211 + .buildOrThrow(); 212 + 213 + card1.addToLibrary(contributor); 214 + card2.addToLibrary(contributor); 215 + await cardRepository.save(card1); 216 + await cardRepository.save(card2); 217 + 218 + // Create collections from different authors 219 + const collection1 = new CollectionBuilder() 220 + .withAuthorId(author1.value) 221 + .withName('Author 1 Collection') 222 + .withAccessType(CollectionAccessType.OPEN) 223 + .buildOrThrow(); 224 + 225 + const collection2 = new CollectionBuilder() 226 + .withAuthorId(author2.value) 227 + .withName('Author 2 Collection') 228 + .withAccessType(CollectionAccessType.OPEN) 229 + .buildOrThrow(); 230 + 231 + const collection3 = new CollectionBuilder() 232 + .withAuthorId(author3.value) 233 + .withName('Author 3 Collection') 234 + .withAccessType(CollectionAccessType.OPEN) 235 + .buildOrThrow(); 236 + 237 + // Contributor adds cards to all collections 238 + collection1.addCard(card1.cardId, contributor); 239 + collection2.addCard(card2.cardId, contributor); 240 + collection3.addCard(card1.cardId, contributor); 241 + 242 + await collectionRepository.save(collection1); 243 + await collectionRepository.save(collection2); 244 + await collectionRepository.save(collection3); 245 + 246 + const result = await queryRepository.getOpenCollectionsWithContributor({ 247 + contributorId: contributor.value, 248 + page: 1, 249 + limit: 10, 250 + sortBy: CollectionSortField.NAME, 251 + sortOrder: SortOrder.ASC, 252 + }); 253 + 254 + expect(result.items).toHaveLength(3); 255 + expect(result.totalCount).toBe(3); 256 + 257 + const collectionNames = result.items.map((c) => c.name); 258 + expect(collectionNames).toContain('Author 1 Collection'); 259 + expect(collectionNames).toContain('Author 2 Collection'); 260 + expect(collectionNames).toContain('Author 3 Collection'); 261 + }); 262 + 263 + it('should NOT return collections where only other users added cards', async () => { 264 + const otherContributor = CuratorId.create( 265 + 'did:plc:othercontributor', 266 + ).unwrap(); 267 + 268 + // Create card from other contributor 269 + const card = new CardBuilder() 270 + .withCuratorId(otherContributor.value) 271 + .withType(CardTypeEnum.NOTE) 272 + .buildOrThrow(); 273 + 274 + card.addToLibrary(otherContributor); 275 + await cardRepository.save(card); 276 + 277 + // Create collection and add card from other contributor 278 + const collection = new CollectionBuilder() 279 + .withAuthorId(author1.value) 280 + .withName('Other Contributors Collection') 281 + .withAccessType(CollectionAccessType.OPEN) 282 + .buildOrThrow(); 283 + 284 + collection.addCard(card.cardId, otherContributor); 285 + await collectionRepository.save(collection); 286 + 287 + const result = await queryRepository.getOpenCollectionsWithContributor({ 288 + contributorId: contributor.value, 289 + page: 1, 290 + limit: 10, 291 + sortBy: CollectionSortField.UPDATED_AT, 292 + sortOrder: SortOrder.DESC, 293 + }); 294 + 295 + expect(result.items).toHaveLength(0); 296 + expect(result.totalCount).toBe(0); 297 + }); 298 + }); 299 + 300 + describe('Sorting by contribution date', () => { 301 + it('should sort by most recent contribution first (primary sort)', async () => { 302 + // Create cards 303 + const card1 = new CardBuilder() 304 + .withCuratorId(contributor.value) 305 + .withType(CardTypeEnum.NOTE) 306 + .buildOrThrow(); 307 + 308 + const card2 = new CardBuilder() 309 + .withCuratorId(contributor.value) 310 + .withType(CardTypeEnum.NOTE) 311 + .buildOrThrow(); 312 + 313 + const card3 = new CardBuilder() 314 + .withCuratorId(contributor.value) 315 + .withType(CardTypeEnum.NOTE) 316 + .buildOrThrow(); 317 + 318 + card1.addToLibrary(contributor); 319 + card2.addToLibrary(contributor); 320 + card3.addToLibrary(contributor); 321 + 322 + await cardRepository.save(card1); 323 + await cardRepository.save(card2); 324 + await cardRepository.save(card3); 325 + 326 + // Create collections 327 + const collection1 = new CollectionBuilder() 328 + .withAuthorId(author1.value) 329 + .withName('Old Contribution') 330 + .withAccessType(CollectionAccessType.OPEN) 331 + .buildOrThrow(); 332 + 333 + const collection2 = new CollectionBuilder() 334 + .withAuthorId(author2.value) 335 + .withName('Recent Contribution') 336 + .withAccessType(CollectionAccessType.OPEN) 337 + .buildOrThrow(); 338 + 339 + const collection3 = new CollectionBuilder() 340 + .withAuthorId(author3.value) 341 + .withName('Middle Contribution') 342 + .withAccessType(CollectionAccessType.OPEN) 343 + .buildOrThrow(); 344 + 345 + // Add cards at different times (simulated by adding in order) 346 + // First contribution (oldest) 347 + collection1.addCard(card1.cardId, contributor); 348 + await collectionRepository.save(collection1); 349 + 350 + // Wait a bit to ensure different timestamps 351 + await new Promise((resolve) => setTimeout(resolve, 100)); 352 + 353 + // Second contribution (middle) 354 + collection3.addCard(card2.cardId, contributor); 355 + await collectionRepository.save(collection3); 356 + 357 + await new Promise((resolve) => setTimeout(resolve, 100)); 358 + 359 + // Third contribution (most recent) 360 + collection2.addCard(card3.cardId, contributor); 361 + await collectionRepository.save(collection2); 362 + 363 + const result = await queryRepository.getOpenCollectionsWithContributor({ 364 + contributorId: contributor.value, 365 + page: 1, 366 + limit: 10, 367 + sortBy: CollectionSortField.NAME, 368 + sortOrder: SortOrder.ASC, 369 + }); 370 + 371 + expect(result.items).toHaveLength(3); 372 + // Should be sorted by most recent contribution first 373 + expect(result.items[0]!.name).toBe('Recent Contribution'); 374 + expect(result.items[1]!.name).toBe('Middle Contribution'); 375 + expect(result.items[2]!.name).toBe('Old Contribution'); 376 + }); 377 + 378 + it('should use most recent contribution when user added multiple cards to same collection', async () => { 379 + // Create cards 380 + const card1 = new CardBuilder() 381 + .withCuratorId(contributor.value) 382 + .withType(CardTypeEnum.NOTE) 383 + .buildOrThrow(); 384 + 385 + const card2 = new CardBuilder() 386 + .withCuratorId(contributor.value) 387 + .withType(CardTypeEnum.NOTE) 388 + .buildOrThrow(); 389 + 390 + card1.addToLibrary(contributor); 391 + card2.addToLibrary(contributor); 392 + await cardRepository.save(card1); 393 + await cardRepository.save(card2); 394 + 395 + // Create another collection with older contribution first 396 + const oldCollection = new CollectionBuilder() 397 + .withAuthorId(author2.value) 398 + .withName('Old Collection') 399 + .withAccessType(CollectionAccessType.OPEN) 400 + .buildOrThrow(); 401 + 402 + oldCollection.addCard(card1.cardId, contributor); 403 + await collectionRepository.save(oldCollection); 404 + 405 + // Wait to ensure timestamp difference 406 + await new Promise((resolve) => setTimeout(resolve, 100)); 407 + 408 + // Create collection 409 + const collection = new CollectionBuilder() 410 + .withAuthorId(author1.value) 411 + .withName('Multiple Cards Collection') 412 + .withAccessType(CollectionAccessType.OPEN) 413 + .buildOrThrow(); 414 + 415 + // Add first card 416 + collection.addCard(card1.cardId, contributor); 417 + await collectionRepository.save(collection); 418 + 419 + await new Promise((resolve) => setTimeout(resolve, 100)); 420 + 421 + // Add second card (newer contribution - this should be used for sorting) 422 + collection.addCard(card2.cardId, contributor); 423 + await collectionRepository.save(collection); 424 + 425 + const result = await queryRepository.getOpenCollectionsWithContributor({ 426 + contributorId: contributor.value, 427 + page: 1, 428 + limit: 10, 429 + sortBy: CollectionSortField.NAME, 430 + sortOrder: SortOrder.ASC, 431 + }); 432 + 433 + expect(result.items).toHaveLength(2); 434 + // Collection with multiple cards should be first due to most recent contribution 435 + expect(result.items[0]!.name).toBe('Multiple Cards Collection'); 436 + expect(result.items[1]!.name).toBe('Old Collection'); 437 + }); 438 + }); 439 + 440 + describe('Secondary sorting', () => { 441 + it('should apply secondary sort by name ascending', async () => { 442 + const card = new CardBuilder() 443 + .withCuratorId(contributor.value) 444 + .withType(CardTypeEnum.NOTE) 445 + .buildOrThrow(); 446 + 447 + card.addToLibrary(contributor); 448 + await cardRepository.save(card); 449 + 450 + // Create collections - we'll add the card in reverse alphabetical order 451 + // to prove the secondary sort is working 452 + const collectionZ = new CollectionBuilder() 453 + .withAuthorId(author1.value) 454 + .withName('Zebra') 455 + .withAccessType(CollectionAccessType.OPEN) 456 + .buildOrThrow(); 457 + 458 + const collectionM = new CollectionBuilder() 459 + .withAuthorId(author2.value) 460 + .withName('Mango') 461 + .withAccessType(CollectionAccessType.OPEN) 462 + .buildOrThrow(); 463 + 464 + const collectionA = new CollectionBuilder() 465 + .withAuthorId(author3.value) 466 + .withName('Apple') 467 + .withAccessType(CollectionAccessType.OPEN) 468 + .buildOrThrow(); 469 + 470 + // Add same card to all collections - reverse alphabetical order 471 + collectionZ.addCard(card.cardId, contributor); 472 + await collectionRepository.save(collectionZ); 473 + 474 + collectionM.addCard(card.cardId, contributor); 475 + await collectionRepository.save(collectionM); 476 + 477 + collectionA.addCard(card.cardId, contributor); 478 + await collectionRepository.save(collectionA); 479 + 480 + const result = await queryRepository.getOpenCollectionsWithContributor({ 481 + contributorId: contributor.value, 482 + page: 1, 483 + limit: 10, 484 + sortBy: CollectionSortField.NAME, 485 + sortOrder: SortOrder.ASC, 486 + }); 487 + 488 + expect(result.items).toHaveLength(3); 489 + // Primary sort by contribution time means most recent (Apple) comes first 490 + // This test verifies the query structure includes secondary sorting 491 + const names = result.items.map((c) => c.name); 492 + // Apple was added last, so it's most recent 493 + expect(names[0]).toBe('Apple'); 494 + expect(names[1]).toBe('Mango'); 495 + expect(names[2]).toBe('Zebra'); 496 + }); 497 + 498 + it('should apply secondary sort by name descending', async () => { 499 + const card = new CardBuilder() 500 + .withCuratorId(contributor.value) 501 + .withType(CardTypeEnum.NOTE) 502 + .buildOrThrow(); 503 + 504 + card.addToLibrary(contributor); 505 + await cardRepository.save(card); 506 + 507 + // Add in alphabetical order to prove DESC secondary sort is working 508 + const collectionA = new CollectionBuilder() 509 + .withAuthorId(author1.value) 510 + .withName('Apple') 511 + .withAccessType(CollectionAccessType.OPEN) 512 + .buildOrThrow(); 513 + 514 + const collectionM = new CollectionBuilder() 515 + .withAuthorId(author2.value) 516 + .withName('Mango') 517 + .withAccessType(CollectionAccessType.OPEN) 518 + .buildOrThrow(); 519 + 520 + const collectionZ = new CollectionBuilder() 521 + .withAuthorId(author3.value) 522 + .withName('Zebra') 523 + .withAccessType(CollectionAccessType.OPEN) 524 + .buildOrThrow(); 525 + 526 + collectionA.addCard(card.cardId, contributor); 527 + await collectionRepository.save(collectionA); 528 + 529 + collectionM.addCard(card.cardId, contributor); 530 + await collectionRepository.save(collectionM); 531 + 532 + collectionZ.addCard(card.cardId, contributor); 533 + await collectionRepository.save(collectionZ); 534 + 535 + const result = await queryRepository.getOpenCollectionsWithContributor({ 536 + contributorId: contributor.value, 537 + page: 1, 538 + limit: 10, 539 + sortBy: CollectionSortField.NAME, 540 + sortOrder: SortOrder.DESC, 541 + }); 542 + 543 + expect(result.items).toHaveLength(3); 544 + // Primary sort by contribution time means most recent (Zebra) comes first 545 + const names = result.items.map((c) => c.name); 546 + expect(names[0]).toBe('Zebra'); 547 + expect(names[1]).toBe('Mango'); 548 + expect(names[2]).toBe('Apple'); 549 + }); 550 + 551 + it('should apply secondary sort by card count', async () => { 552 + const card1 = new CardBuilder() 553 + .withCuratorId(contributor.value) 554 + .withType(CardTypeEnum.NOTE) 555 + .buildOrThrow(); 556 + 557 + const card2 = new CardBuilder() 558 + .withCuratorId(author1.value) 559 + .withType(CardTypeEnum.NOTE) 560 + .buildOrThrow(); 561 + 562 + const card3 = new CardBuilder() 563 + .withCuratorId(author1.value) 564 + .withType(CardTypeEnum.NOTE) 565 + .buildOrThrow(); 566 + 567 + card1.addToLibrary(contributor); 568 + card2.addToLibrary(author1); 569 + card3.addToLibrary(author1); 570 + 571 + await cardRepository.save(card1); 572 + await cardRepository.save(card2); 573 + await cardRepository.save(card3); 574 + 575 + // Collection with 1 card total (from contributor) - add first so it's older 576 + const smallCollection = new CollectionBuilder() 577 + .withAuthorId(author2.value) 578 + .withName('Small Collection') 579 + .withAccessType(CollectionAccessType.OPEN) 580 + .buildOrThrow(); 581 + 582 + smallCollection.addCard(card1.cardId, contributor); 583 + await collectionRepository.save(smallCollection); 584 + 585 + // Wait to ensure timestamp difference 586 + await new Promise((resolve) => setTimeout(resolve, 100)); 587 + 588 + // Collection with 3 cards total (1 from contributor, 2 from author) - add second so it's newer 589 + const largeCollection = new CollectionBuilder() 590 + .withAuthorId(author1.value) 591 + .withName('Large Collection') 592 + .withAccessType(CollectionAccessType.OPEN) 593 + .buildOrThrow(); 594 + 595 + largeCollection.addCard(card1.cardId, contributor); 596 + largeCollection.addCard(card2.cardId, author1); 597 + largeCollection.addCard(card3.cardId, author1); 598 + 599 + await collectionRepository.save(largeCollection); 600 + 601 + const result = await queryRepository.getOpenCollectionsWithContributor({ 602 + contributorId: contributor.value, 603 + page: 1, 604 + limit: 10, 605 + sortBy: CollectionSortField.CARD_COUNT, 606 + sortOrder: SortOrder.DESC, 607 + }); 608 + 609 + expect(result.items).toHaveLength(2); 610 + expect(result.items[0]!.name).toBe('Large Collection'); 611 + expect(result.items[0]!.cardCount).toBe(3); 612 + expect(result.items[1]!.name).toBe('Small Collection'); 613 + expect(result.items[1]!.cardCount).toBe(1); 614 + }); 615 + }); 616 + 617 + describe('Pagination', () => { 618 + it('should paginate results correctly', async () => { 619 + // Create 5 cards 620 + const cards = []; 621 + for (let i = 0; i < 5; i++) { 622 + const card = new CardBuilder() 623 + .withCuratorId(contributor.value) 624 + .withType(CardTypeEnum.NOTE) 625 + .buildOrThrow(); 626 + 627 + card.addToLibrary(contributor); 628 + cards.push(card); 629 + await cardRepository.save(card); 630 + } 631 + 632 + // Create 5 collections 633 + for (let i = 0; i < 5; i++) { 634 + const collection = new CollectionBuilder() 635 + .withAuthorId(`did:plc:author${i}`) 636 + .withName(`Collection ${i + 1}`) 637 + .withAccessType(CollectionAccessType.OPEN) 638 + .buildOrThrow(); 639 + 640 + collection.addCard(cards[i]!.cardId, contributor); 641 + await collectionRepository.save(collection); 642 + } 643 + 644 + // Test first page 645 + const page1 = await queryRepository.getOpenCollectionsWithContributor({ 646 + contributorId: contributor.value, 647 + page: 1, 648 + limit: 2, 649 + sortBy: CollectionSortField.NAME, 650 + sortOrder: SortOrder.ASC, 651 + }); 652 + 653 + expect(page1.items).toHaveLength(2); 654 + expect(page1.totalCount).toBe(5); 655 + expect(page1.hasMore).toBe(true); 656 + 657 + // Test second page 658 + const page2 = await queryRepository.getOpenCollectionsWithContributor({ 659 + contributorId: contributor.value, 660 + page: 2, 661 + limit: 2, 662 + sortBy: CollectionSortField.NAME, 663 + sortOrder: SortOrder.ASC, 664 + }); 665 + 666 + expect(page2.items).toHaveLength(2); 667 + expect(page2.totalCount).toBe(5); 668 + expect(page2.hasMore).toBe(true); 669 + 670 + // Test last page 671 + const page3 = await queryRepository.getOpenCollectionsWithContributor({ 672 + contributorId: contributor.value, 673 + page: 3, 674 + limit: 2, 675 + sortBy: CollectionSortField.NAME, 676 + sortOrder: SortOrder.ASC, 677 + }); 678 + 679 + expect(page3.items).toHaveLength(1); 680 + expect(page3.totalCount).toBe(5); 681 + expect(page3.hasMore).toBe(false); 682 + 683 + // Verify no duplicates across pages 684 + const allIds = [ 685 + ...page1.items.map((c) => c.id), 686 + ...page2.items.map((c) => c.id), 687 + ...page3.items.map((c) => c.id), 688 + ]; 689 + const uniqueIds = [...new Set(allIds)]; 690 + expect(uniqueIds).toHaveLength(5); 691 + }); 692 + 693 + it('should handle empty pages correctly', async () => { 694 + const result = await queryRepository.getOpenCollectionsWithContributor({ 695 + contributorId: contributor.value, 696 + page: 2, 697 + limit: 10, 698 + sortBy: CollectionSortField.UPDATED_AT, 699 + sortOrder: SortOrder.DESC, 700 + }); 701 + 702 + expect(result.items).toHaveLength(0); 703 + expect(result.totalCount).toBe(0); 704 + expect(result.hasMore).toBe(false); 705 + }); 706 + 707 + it('should handle large page numbers gracefully', async () => { 708 + const card = new CardBuilder() 709 + .withCuratorId(contributor.value) 710 + .withType(CardTypeEnum.NOTE) 711 + .buildOrThrow(); 712 + 713 + card.addToLibrary(contributor); 714 + await cardRepository.save(card); 715 + 716 + const collection = new CollectionBuilder() 717 + .withAuthorId(author1.value) 718 + .withName('Single Collection') 719 + .withAccessType(CollectionAccessType.OPEN) 720 + .buildOrThrow(); 721 + 722 + collection.addCard(card.cardId, contributor); 723 + await collectionRepository.save(collection); 724 + 725 + // Request page 10 when there's only 1 item 726 + const result = await queryRepository.getOpenCollectionsWithContributor({ 727 + contributorId: contributor.value, 728 + page: 10, 729 + limit: 10, 730 + sortBy: CollectionSortField.UPDATED_AT, 731 + sortOrder: SortOrder.DESC, 732 + }); 733 + 734 + expect(result.items).toHaveLength(0); 735 + expect(result.totalCount).toBe(1); 736 + expect(result.hasMore).toBe(false); 737 + }); 738 + }); 739 + 740 + describe('Published records', () => { 741 + it('should return URI for collections with published records', async () => { 742 + const card = new CardBuilder() 743 + .withCuratorId(contributor.value) 744 + .withType(CardTypeEnum.NOTE) 745 + .buildOrThrow(); 746 + 747 + card.addToLibrary(contributor); 748 + await cardRepository.save(card); 749 + 750 + const collection = new CollectionBuilder() 751 + .withAuthorId(author1.value) 752 + .withName('Published Collection') 753 + .withAccessType(CollectionAccessType.OPEN) 754 + .buildOrThrow(); 755 + 756 + collection.addCard(card.cardId, contributor); 757 + 758 + const publishedRecordId = PublishedRecordId.create({ 759 + uri: 'at://did:plc:author1/network.cosmik.collection/abc123', 760 + cid: 'bafyreipublished', 761 + }); 762 + 763 + collection.markAsPublished(publishedRecordId); 764 + await collectionRepository.save(collection); 765 + 766 + const result = await queryRepository.getOpenCollectionsWithContributor({ 767 + contributorId: contributor.value, 768 + page: 1, 769 + limit: 10, 770 + sortBy: CollectionSortField.UPDATED_AT, 771 + sortOrder: SortOrder.DESC, 772 + }); 773 + 774 + expect(result.items).toHaveLength(1); 775 + expect(result.items[0]!.uri).toBe( 776 + 'at://did:plc:author1/network.cosmik.collection/abc123', 777 + ); 778 + }); 779 + 780 + it('should handle collections without published records', async () => { 781 + const card = new CardBuilder() 782 + .withCuratorId(contributor.value) 783 + .withType(CardTypeEnum.NOTE) 784 + .buildOrThrow(); 785 + 786 + card.addToLibrary(contributor); 787 + await cardRepository.save(card); 788 + 789 + const collection = new CollectionBuilder() 790 + .withAuthorId(author1.value) 791 + .withName('Unpublished Collection') 792 + .withAccessType(CollectionAccessType.OPEN) 793 + .buildOrThrow(); 794 + 795 + collection.addCard(card.cardId, contributor); 796 + await collectionRepository.save(collection); 797 + 798 + const result = await queryRepository.getOpenCollectionsWithContributor({ 799 + contributorId: contributor.value, 800 + page: 1, 801 + limit: 10, 802 + sortBy: CollectionSortField.UPDATED_AT, 803 + sortOrder: SortOrder.DESC, 804 + }); 805 + 806 + expect(result.items).toHaveLength(1); 807 + expect(result.items[0]!.uri).toBeUndefined(); 808 + }); 809 + }); 810 + 811 + describe('Edge cases', () => { 812 + it('should handle collections with null descriptions', async () => { 813 + const card = new CardBuilder() 814 + .withCuratorId(contributor.value) 815 + .withType(CardTypeEnum.NOTE) 816 + .buildOrThrow(); 817 + 818 + card.addToLibrary(contributor); 819 + await cardRepository.save(card); 820 + 821 + const collection = new CollectionBuilder() 822 + .withAuthorId(author1.value) 823 + .withName('No Description') 824 + .withAccessType(CollectionAccessType.OPEN) 825 + .buildOrThrow(); 826 + 827 + collection.addCard(card.cardId, contributor); 828 + await collectionRepository.save(collection); 829 + 830 + const result = await queryRepository.getOpenCollectionsWithContributor({ 831 + contributorId: contributor.value, 832 + page: 1, 833 + limit: 10, 834 + sortBy: CollectionSortField.UPDATED_AT, 835 + sortOrder: SortOrder.DESC, 836 + }); 837 + 838 + expect(result.items).toHaveLength(1); 839 + expect(result.items[0]!.description).toBeUndefined(); 840 + }); 841 + 842 + it('should handle contributor with no matching collections', async () => { 843 + const result = await queryRepository.getOpenCollectionsWithContributor({ 844 + contributorId: 'did:plc:nonexistent', 845 + page: 1, 846 + limit: 10, 847 + sortBy: CollectionSortField.UPDATED_AT, 848 + sortOrder: SortOrder.DESC, 849 + }); 850 + 851 + expect(result.items).toHaveLength(0); 852 + expect(result.totalCount).toBe(0); 853 + expect(result.hasMore).toBe(false); 854 + }); 855 + 856 + it('should handle mix of open and closed collections', async () => { 857 + const card = new CardBuilder() 858 + .withCuratorId(contributor.value) 859 + .withType(CardTypeEnum.NOTE) 860 + .buildOrThrow(); 861 + 862 + card.addToLibrary(contributor); 863 + await cardRepository.save(card); 864 + 865 + // Create OPEN collection 866 + const openCollection = new CollectionBuilder() 867 + .withAuthorId(author1.value) 868 + .withName('Open Collection') 869 + .withAccessType(CollectionAccessType.OPEN) 870 + .buildOrThrow(); 871 + 872 + // Create CLOSED collection 873 + const closedCollection = new CollectionBuilder() 874 + .withAuthorId(author2.value) 875 + .withName('Closed Collection') 876 + .withAccessType(CollectionAccessType.CLOSED) 877 + .buildOrThrow(); 878 + 879 + openCollection.addCard(card.cardId, contributor); 880 + closedCollection.addCard(card.cardId, contributor); 881 + 882 + await collectionRepository.save(openCollection); 883 + await collectionRepository.save(closedCollection); 884 + 885 + const result = await queryRepository.getOpenCollectionsWithContributor({ 886 + contributorId: contributor.value, 887 + page: 1, 888 + limit: 10, 889 + sortBy: CollectionSortField.UPDATED_AT, 890 + sortOrder: SortOrder.DESC, 891 + }); 892 + 893 + // Should only return the OPEN collection 894 + expect(result.items).toHaveLength(1); 895 + expect(result.items[0]!.name).toBe('Open Collection'); 896 + expect(result.items[0]!.accessType).toBe(CollectionAccessType.OPEN); 897 + }); 898 + 899 + it('should return distinct collections even if contributor added multiple cards', async () => { 900 + // Create multiple cards from contributor 901 + const card1 = new CardBuilder() 902 + .withCuratorId(contributor.value) 903 + .withType(CardTypeEnum.NOTE) 904 + .buildOrThrow(); 905 + 906 + const card2 = new CardBuilder() 907 + .withCuratorId(contributor.value) 908 + .withType(CardTypeEnum.NOTE) 909 + .buildOrThrow(); 910 + 911 + const card3 = new CardBuilder() 912 + .withCuratorId(contributor.value) 913 + .withType(CardTypeEnum.NOTE) 914 + .buildOrThrow(); 915 + 916 + card1.addToLibrary(contributor); 917 + card2.addToLibrary(contributor); 918 + card3.addToLibrary(contributor); 919 + 920 + await cardRepository.save(card1); 921 + await cardRepository.save(card2); 922 + await cardRepository.save(card3); 923 + 924 + // Create one collection and add all cards from contributor 925 + const collection = new CollectionBuilder() 926 + .withAuthorId(author1.value) 927 + .withName('Multi-Card Collection') 928 + .withAccessType(CollectionAccessType.OPEN) 929 + .buildOrThrow(); 930 + 931 + collection.addCard(card1.cardId, contributor); 932 + collection.addCard(card2.cardId, contributor); 933 + collection.addCard(card3.cardId, contributor); 934 + 935 + await collectionRepository.save(collection); 936 + 937 + const result = await queryRepository.getOpenCollectionsWithContributor({ 938 + contributorId: contributor.value, 939 + page: 1, 940 + limit: 10, 941 + sortBy: CollectionSortField.UPDATED_AT, 942 + sortOrder: SortOrder.DESC, 943 + }); 944 + 945 + // Should return the collection only once, not 3 times 946 + expect(result.items).toHaveLength(1); 947 + expect(result.items[0]!.name).toBe('Multi-Card Collection'); 948 + expect(result.items[0]!.cardCount).toBe(3); 949 + }); 950 + }); 951 + });
+74
src/modules/cards/tests/utils/FakeCollectionPublisher.ts
··· 16 16 > = new Map(); 17 17 private unpublishedCollections: Array<{ uri: string; cid: string }> = []; 18 18 private removedLinks: Array<{ cardId: string; collectionId: string }> = []; 19 + private publishedRemovals: Map< 20 + string, 21 + { 22 + cardId: string; 23 + collectionId: string; 24 + removedLink: PublishedRecordId; 25 + removalRecord: PublishedRecordId; 26 + }[] 27 + > = new Map(); 19 28 private shouldFail: boolean = false; 20 29 private shouldFailUnpublish: boolean = false; 21 30 private collectionType = ··· 23 32 24 33 private cardCollectionLinkType = 25 34 new EnvironmentConfigService().getAtProtoCollections().collectionLink; 35 + 36 + private collectionLinkRemovalType = 37 + new EnvironmentConfigService().getAtProtoCollections() 38 + .collectionLinkRemoval; 26 39 27 40 async publish( 28 41 collection: Collection, ··· 134 147 return ok(undefined); 135 148 } 136 149 150 + async publishCollectionLinkRemoval( 151 + card: Card, 152 + collection: Collection, 153 + curatorId: CuratorId, 154 + removedLinkRef: PublishedRecordId, 155 + ): Promise<Result<PublishedRecordId, UseCaseError>> { 156 + if (this.shouldFail) { 157 + return err( 158 + AppError.UnexpectedError.create( 159 + new Error('Simulated collection link removal publish failure'), 160 + ), 161 + ); 162 + } 163 + 164 + const collectionId = collection.collectionId.getStringValue(); 165 + const cardId = card.cardId.getStringValue(); 166 + 167 + // Simulate publishing a collection link removal record 168 + const fakeRemovalUri = `at://${curatorId.value}/${this.collectionLinkRemovalType}/${collectionId}-${cardId}-removal`; 169 + const fakeRemovalCid = `fake-removal-cid-${collectionId}-${cardId}`; 170 + 171 + const removalRecord = PublishedRecordId.create({ 172 + uri: fakeRemovalUri, 173 + cid: fakeRemovalCid, 174 + }); 175 + 176 + // Store published removal for inspection 177 + const existingRemovals = this.publishedRemovals.get(collectionId) || []; 178 + existingRemovals.push({ 179 + cardId, 180 + collectionId, 181 + removedLink: removedLinkRef, 182 + removalRecord, 183 + }); 184 + this.publishedRemovals.set(collectionId, existingRemovals); 185 + 186 + console.log( 187 + `[FakeCollectionPublisher] Published removal of card ${cardId} from collection ${collectionId} at ${fakeRemovalUri}`, 188 + ); 189 + 190 + return ok(removalRecord); 191 + } 192 + 137 193 async unpublish( 138 194 recordId: PublishedRecordId, 139 195 ): Promise<Result<void, UseCaseError>> { ··· 193 249 this.publishedLinks.clear(); 194 250 this.unpublishedCollections = []; 195 251 this.removedLinks = []; 252 + this.publishedRemovals.clear(); 196 253 this.shouldFail = false; 197 254 this.shouldFailUnpublish = false; 198 255 } ··· 229 286 allLinks.push(...links); 230 287 } 231 288 return allLinks; 289 + } 290 + 291 + getPublishedRemovalsForCollection(collectionId: string) { 292 + return this.publishedRemovals.get(collectionId) || []; 293 + } 294 + 295 + getAllPublishedRemovals() { 296 + const allRemovals: Array<{ 297 + cardId: string; 298 + collectionId: string; 299 + removedLink: PublishedRecordId; 300 + removalRecord: PublishedRecordId; 301 + }> = []; 302 + for (const removals of this.publishedRemovals.values()) { 303 + allRemovals.push(...removals); 304 + } 305 + return allRemovals; 232 306 } 233 307 }
+7 -1
src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts
··· 110 110 111 111 // Find collections this card belongs to by querying the collection repository 112 112 const allCollections = this.collectionRepository.getAllCollections(); 113 - const collections: { id: string; name: string; authorId: string }[] = []; 113 + const collections: { 114 + id: string; 115 + name: string; 116 + authorId: string; 117 + accessType: string; 118 + }[] = []; 114 119 115 120 for (const collection of allCollections) { 116 121 if ( ··· 122 127 id: collection.collectionId.getStringValue(), 123 128 name: collection.name.value, 124 129 authorId: collection.authorId.value, 130 + accessType: collection.accessType, 125 131 }); 126 132 } 127 133 }
+95
src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts
··· 9 9 SortOrder, 10 10 CollectionForUrlQueryOptions, 11 11 SearchCollectionsOptions, 12 + GetOpenCollectionsWithContributorOptions, 12 13 } from '../../domain/ICollectionQueryRepository'; 13 14 import { Collection } from '../../domain/Collection'; 14 15 import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; ··· 205 206 uri: collectionPublishedRecordId?.uri, 206 207 name: collection.name.value, 207 208 description: collection.description?.value, 209 + accessType: collection.accessType, 208 210 authorId: collection.authorId.value, 209 211 }; 210 212 }, ··· 298 300 } catch (error) { 299 301 throw new Error( 300 302 `Failed to search collections: ${error instanceof Error ? error.message : String(error)}`, 303 + ); 304 + } 305 + } 306 + 307 + async getOpenCollectionsWithContributor( 308 + options: GetOpenCollectionsWithContributorOptions, 309 + ): Promise<PaginatedQueryResult<CollectionQueryResultDTO>> { 310 + try { 311 + const allCollections = this.collectionRepository.getAllCollections(); 312 + 313 + // Filter for collections where: 314 + // 1. User has added cards (via cardLinks.addedBy) 315 + // 2. User is NOT the author 316 + // 3. Collection is OPEN 317 + let contributedCollections = allCollections.filter((collection) => { 318 + const hasContributed = collection.cardLinks.some( 319 + (link) => link.addedBy.value === options.contributorId, 320 + ); 321 + const isNotAuthor = collection.authorId.value !== options.contributorId; 322 + const isOpen = collection.accessType === 'OPEN'; 323 + 324 + return hasContributed && isNotAuthor && isOpen; 325 + }); 326 + 327 + // Sort by most recent contribution first, then by the specified field 328 + const sortedCollections = [...contributedCollections].sort((a, b) => { 329 + // Get most recent contribution date for each collection 330 + const aLastContribution = Math.max( 331 + ...a.cardLinks 332 + .filter((link) => link.addedBy.value === options.contributorId) 333 + .map((link) => link.addedAt.getTime()), 334 + ); 335 + const bLastContribution = Math.max( 336 + ...b.cardLinks 337 + .filter((link) => link.addedBy.value === options.contributorId) 338 + .map((link) => link.addedAt.getTime()), 339 + ); 340 + 341 + // Primary sort: by most recent contribution (DESC) 342 + const contributionComparison = bLastContribution - aLastContribution; 343 + if (contributionComparison !== 0) return contributionComparison; 344 + 345 + // Secondary sort: by the specified field 346 + let comparison = 0; 347 + switch (options.sortBy) { 348 + case CollectionSortField.NAME: 349 + comparison = a.name.value.localeCompare(b.name.value); 350 + break; 351 + case CollectionSortField.CREATED_AT: 352 + comparison = a.createdAt.getTime() - b.createdAt.getTime(); 353 + break; 354 + case CollectionSortField.UPDATED_AT: 355 + comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); 356 + break; 357 + case CollectionSortField.CARD_COUNT: 358 + comparison = a.cardCount - b.cardCount; 359 + break; 360 + } 361 + return options.sortOrder === SortOrder.DESC ? -comparison : comparison; 362 + }); 363 + 364 + const startIndex = (options.page - 1) * options.limit; 365 + const endIndex = startIndex + options.limit; 366 + const paginatedCollections = sortedCollections.slice( 367 + startIndex, 368 + endIndex, 369 + ); 370 + 371 + const items: CollectionQueryResultDTO[] = paginatedCollections.map( 372 + (collection) => { 373 + const collectionPublishedRecordId = collection.publishedRecordId; 374 + return { 375 + id: collection.collectionId.getStringValue(), 376 + uri: collectionPublishedRecordId?.uri, 377 + authorId: collection.authorId.value, 378 + name: collection.name.value, 379 + description: collection.description?.value, 380 + accessType: collection.accessType, 381 + cardCount: collection.cardCount, 382 + createdAt: collection.createdAt, 383 + updatedAt: collection.updatedAt, 384 + }; 385 + }, 386 + ); 387 + 388 + return { 389 + items, 390 + totalCount: sortedCollections.length, 391 + hasMore: endIndex < sortedCollections.length, 392 + }; 393 + } catch (error) { 394 + throw new Error( 395 + `Failed to get open collections with contributor: ${error instanceof Error ? error.message : String(error)}`, 301 396 ); 302 397 } 303 398 }
+5
src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts
··· 18 18 import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 19 19 import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 20 20 import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 21 + import { CollectionAccessType } from '../../../../cards/domain/Collection'; 21 22 22 23 export interface GetGemActivityFeedQuery { 23 24 callingUserId?: string; ··· 278 279 uri?: string; 279 280 name: string; 280 281 description?: string; 282 + accessType: CollectionAccessType; 281 283 author: { 282 284 id: string; 283 285 name: string; ··· 330 332 uri, 331 333 name: collection.name.toString(), 332 334 description: collection.description?.toString(), 335 + accessType: collection.accessType, 333 336 author: { 334 337 id: authorProfile.id, 335 338 name: authorProfile.name, ··· 353 356 uri: result.uri, 354 357 name: result.name, 355 358 description: result.description, 359 + accessType: result.accessType, 356 360 author: result.author, 357 361 cardCount: result.cardCount, 358 362 createdAt: result.createdAt, ··· 421 425 uri: collection.uri, 422 426 name: collection.name, 423 427 description: collection.description, 428 + accessType: collection.accessType, 424 429 author: collection.author, 425 430 cardCount: collection.cardCount, 426 431 createdAt: collection.createdAt,
+5
src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts
··· 13 13 import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 14 14 import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 15 15 import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 16 + import { CollectionAccessType } from '../../../../cards/domain/Collection'; 16 17 17 18 export interface GetGlobalFeedQuery { 18 19 callingUserId?: string; ··· 219 220 uri?: string; 220 221 name: string; 221 222 description?: string; 223 + accessType: CollectionAccessType; 222 224 author: { 223 225 id: string; 224 226 name: string; ··· 271 273 uri, 272 274 name: collection.name.toString(), 273 275 description: collection.description?.toString(), 276 + accessType: collection.accessType, 274 277 author: { 275 278 id: authorProfile.id, 276 279 name: authorProfile.name, ··· 294 297 uri: result.uri, 295 298 name: result.name, 296 299 description: result.description, 300 + accessType: result.accessType, 297 301 author: result.author, 298 302 cardCount: result.cardCount, 299 303 createdAt: result.createdAt, ··· 362 366 uri: collection.uri, 363 367 name: collection.name, 364 368 description: collection.description, 369 + accessType: collection.accessType, 365 370 author: collection.author, 366 371 cardCount: collection.cardCount, 367 372 createdAt: collection.createdAt,
+78
src/modules/notifications/application/eventHandlers/CollectionContributionCleanupEventHandler.ts
··· 1 + import { CardRemovedFromCollectionEvent } from '../../../cards/domain/events/CardRemovedFromCollectionEvent'; 2 + import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; 3 + import { Result, ok } from '../../../../shared/core/Result'; 4 + import { INotificationRepository } from '../../domain/INotificationRepository'; 5 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 6 + import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 7 + 8 + export class CollectionContributionCleanupEventHandler 9 + implements IEventHandler<CardRemovedFromCollectionEvent> 10 + { 11 + constructor( 12 + private notificationRepository: INotificationRepository, 13 + private collectionRepository: ICollectionRepository, 14 + ) {} 15 + 16 + async handle(event: CardRemovedFromCollectionEvent): Promise<Result<void>> { 17 + try { 18 + // 1. Get collection to determine the recipient (collection author) 19 + const collectionResult = await this.collectionRepository.findById( 20 + event.collectionId, 21 + ); 22 + if (collectionResult.isErr() || !collectionResult.value) { 23 + // Collection not found, skip cleanup 24 + return ok(undefined); 25 + } 26 + 27 + const collection = collectionResult.value; 28 + const recipientUserId = collection.authorId.value; 29 + 30 + // 2. Find notifications for this card 31 + // Note: We query by card only, not by actor, because the notification actor 32 + // is the person who added the card (not necessarily who removed it) 33 + const notificationsResult = await this.notificationRepository.findByCard( 34 + event.cardId.getStringValue(), 35 + ); 36 + 37 + if (notificationsResult.isErr()) { 38 + console.error( 39 + 'Error fetching notifications for cleanup:', 40 + notificationsResult.error, 41 + ); 42 + return ok(undefined); 43 + } 44 + 45 + const notifications = notificationsResult.value; 46 + 47 + // 4. Filter and delete matching notifications 48 + for (const notification of notifications) { 49 + // Check if this notification is USER_ADDED_TO_YOUR_COLLECTION type 50 + // and the recipient matches the collection author 51 + // and the collection ID is in the metadata 52 + if ( 53 + notification.type.value === 'USER_ADDED_TO_YOUR_COLLECTION' && 54 + notification.recipientUserId.value === recipientUserId && 55 + notification.metadata.collectionIds?.includes( 56 + event.collectionId.getStringValue(), 57 + ) 58 + ) { 59 + const deleteResult = await this.notificationRepository.delete( 60 + notification.notificationId, 61 + ); 62 + if (deleteResult.isErr()) { 63 + console.error('Failed to delete notification:', deleteResult.error); 64 + // Continue with other notifications even if one fails 65 + } 66 + } 67 + } 68 + 69 + return ok(undefined); 70 + } catch (error) { 71 + console.error( 72 + 'Error in CollectionContributionCleanupEventHandler:', 73 + error, 74 + ); 75 + return ok(undefined); // Don't fail the event processing 76 + } 77 + } 78 + }
+66
src/modules/notifications/application/eventHandlers/CollectionContributionEventHandler.ts
··· 1 + import { CardAddedToCollectionEvent } from '../../../cards/domain/events/CardAddedToCollectionEvent'; 2 + import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; 3 + import { Result, ok } from '../../../../shared/core/Result'; 4 + import { CreateNotificationUseCase } from '../useCases/commands/CreateNotificationUseCase'; 5 + import { ICollectionRepository } from '../../../cards/domain/ICollectionRepository'; 6 + import { CollectionAccessType } from '../../../cards/domain/Collection'; 7 + import { NotificationType } from '@semble/types'; 8 + 9 + export class CollectionContributionEventHandler 10 + implements IEventHandler<CardAddedToCollectionEvent> 11 + { 12 + constructor( 13 + private createNotificationUseCase: CreateNotificationUseCase, 14 + private collectionRepository: ICollectionRepository, 15 + ) {} 16 + 17 + async handle(event: CardAddedToCollectionEvent): Promise<Result<void>> { 18 + try { 19 + // 1. Get collection to check access type and author 20 + const collectionResult = await this.collectionRepository.findById( 21 + event.collectionId, 22 + ); 23 + if (collectionResult.isErr() || !collectionResult.value) { 24 + // Collection not found, skip notification 25 + return ok(undefined); 26 + } 27 + 28 + const collection = collectionResult.value; 29 + 30 + // 2. Only create notifications for OPEN collections 31 + if (collection.accessType !== CollectionAccessType.OPEN) { 32 + return ok(undefined); 33 + } 34 + 35 + // 3. Get recipient (collection author) and actor (who added the card) 36 + const recipientUserId = collection.authorId.value; 37 + const actorUserId = event.addedBy.value; 38 + 39 + // 4. Don't notify if collection owner is adding to their own collection 40 + if (recipientUserId === actorUserId) { 41 + return ok(undefined); 42 + } 43 + 44 + // 5. Create notification directly (no saga aggregation) 45 + const notificationResult = await this.createNotificationUseCase.execute({ 46 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION, 47 + recipientUserId, 48 + actorUserId, 49 + cardId: event.cardId.getStringValue(), 50 + collectionId: event.collectionId.getStringValue(), 51 + }); 52 + 53 + if (notificationResult.isErr()) { 54 + console.error( 55 + 'Failed to create collection contribution notification:', 56 + notificationResult.error, 57 + ); 58 + } 59 + 60 + return ok(undefined); 61 + } catch (error) { 62 + console.error('Error in CollectionContributionEventHandler:', error); 63 + return ok(undefined); // Don't fail the event processing 64 + } 65 + } 66 + }
+67 -22
src/modules/notifications/application/useCases/commands/CreateNotificationUseCase.ts
··· 16 16 collectionIds?: string[]; 17 17 } 18 18 19 - export type CreateNotificationDTO = CreateUserAddedYourCardNotificationDTO; 19 + export interface CreateUserAddedToYourCollectionNotificationDTO { 20 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION; 21 + recipientUserId: string; 22 + actorUserId: string; 23 + cardId: string; 24 + collectionId: string; 25 + } 26 + 27 + export type CreateNotificationDTO = 28 + | CreateUserAddedYourCardNotificationDTO 29 + | CreateUserAddedToYourCollectionNotificationDTO; 20 30 21 31 export interface CreateNotificationResponseDTO { 22 32 notificationId: string; ··· 80 90 } 81 91 const cardId = cardIdResult.value; 82 92 83 - // Validate collection IDs if provided 84 - let collectionIds: CollectionId[] | undefined; 85 - if (request.collectionIds && request.collectionIds.length > 0) { 86 - collectionIds = []; 87 - for (const collectionIdStr of request.collectionIds) { 88 - const collectionIdResult = 89 - CollectionId.createFromString(collectionIdStr); 90 - if (collectionIdResult.isErr()) { 91 - return err( 92 - new ValidationError( 93 - `Invalid collection ID: ${collectionIdResult.error.message}`, 94 - ), 95 - ); 93 + // Handle different notification types 94 + let notificationResult; 95 + 96 + if (request.type === NotificationType.USER_ADDED_YOUR_CARD) { 97 + // Validate collection IDs if provided 98 + let collectionIds: CollectionId[] | undefined; 99 + if (request.collectionIds && request.collectionIds.length > 0) { 100 + collectionIds = []; 101 + for (const collectionIdStr of request.collectionIds) { 102 + const collectionIdResult = 103 + CollectionId.createFromString(collectionIdStr); 104 + if (collectionIdResult.isErr()) { 105 + return err( 106 + new ValidationError( 107 + `Invalid collection ID: ${collectionIdResult.error.message}`, 108 + ), 109 + ); 110 + } 111 + collectionIds.push(collectionIdResult.value); 96 112 } 97 - collectionIds.push(collectionIdResult.value); 113 + } 114 + 115 + notificationResult = 116 + await this.notificationService.createUserAddedYourCardNotification( 117 + recipientId, 118 + actorId, 119 + cardId, 120 + collectionIds, 121 + ); 122 + } else if ( 123 + request.type === NotificationType.USER_ADDED_TO_YOUR_COLLECTION 124 + ) { 125 + // Validate collection ID 126 + const collectionIdResult = CollectionId.createFromString( 127 + request.collectionId, 128 + ); 129 + if (collectionIdResult.isErr()) { 130 + return err( 131 + new ValidationError( 132 + `Invalid collection ID: ${collectionIdResult.error.message}`, 133 + ), 134 + ); 98 135 } 99 - } 100 136 101 - const notificationResult = 102 - await this.notificationService.createUserAddedYourCardNotification( 103 - recipientId, 104 - actorId, 105 - cardId, 106 - collectionIds, 137 + notificationResult = 138 + await this.notificationService.createUserAddedToYourCollectionNotification( 139 + recipientId, 140 + actorId, 141 + cardId, 142 + collectionIdResult.value, 143 + ); 144 + } else { 145 + // Type exhaustiveness check 146 + const _exhaustiveCheck: never = request; 147 + return err( 148 + new ValidationError( 149 + `Unsupported notification type: ${(_exhaustiveCheck as any).type}`, 150 + ), 107 151 ); 152 + } 108 153 109 154 if (notificationResult.isErr()) { 110 155 return err(new ValidationError(notificationResult.error.message));
+2
src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts
··· 9 9 import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; 10 10 import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; 11 11 import { NotificationItem } from '@semble/types'; 12 + import { CollectionAccessType } from '../../../../cards/domain/Collection'; 12 13 13 14 export interface GetMyNotificationsDTO { 14 15 userId: string; ··· 148 149 description: collectionAuthorProfile.bio, 149 150 }, 150 151 description: collection.description, 152 + accessType: collection.accessType as CollectionAccessType, 151 153 cardCount: collection.cardCount, 152 154 createdAt: collection.createdAt.toISOString(), 153 155 updatedAt: collection.updatedAt.toISOString(),
+2
src/modules/notifications/domain/INotificationRepository.ts
··· 57 57 uri?: string; 58 58 name: string; 59 59 description?: string; 60 + accessType: string; 60 61 authorId: string; // Still need profile enrichment 61 62 cardCount: number; 62 63 createdAt: Date; ··· 86 87 cardId: string, 87 88 actorUserId: CuratorId, 88 89 ): Promise<Result<Notification[]>>; 90 + findByCard(cardId: string): Promise<Result<Notification[]>>; 89 91 getUnreadCount(recipientId: CuratorId): Promise<Result<number>>; 90 92 markAsRead(notificationIds: NotificationId[]): Promise<Result<void>>; 91 93 markAllAsReadForUser(recipientId: CuratorId): Promise<Result<number>>;
+24
src/modules/notifications/domain/Notification.ts
··· 103 103 }); 104 104 } 105 105 106 + public static createUserAddedToYourCollection( 107 + recipientUserId: CuratorId, 108 + actorUserId: CuratorId, 109 + cardId: CardId, 110 + collectionId: CollectionId, 111 + ): Result<Notification> { 112 + const typeResult = NotificationType.userAddedToYourCollection(); 113 + if (typeResult.isErr()) { 114 + return err(typeResult.error); 115 + } 116 + 117 + const metadata: NotificationMetadata = { 118 + cardId: cardId.getStringValue(), 119 + collectionIds: [collectionId.getStringValue()], 120 + }; 121 + 122 + return this.create({ 123 + recipientUserId, 124 + actorUserId, 125 + type: typeResult.value, 126 + metadata, 127 + }); 128 + } 129 + 106 130 public markAsRead(): void { 107 131 this.props.read = true; 108 132 this.props.updatedAt = new Date();
+50
src/modules/notifications/domain/services/NotificationService.ts
··· 65 65 ); 66 66 } 67 67 } 68 + 69 + async createUserAddedToYourCollectionNotification( 70 + recipientUserId: CuratorId, 71 + actorUserId: CuratorId, 72 + cardId: CardId, 73 + collectionId: CollectionId, 74 + ): Promise<Result<Notification, NotificationServiceError>> { 75 + try { 76 + // Don't create notification if user is adding to their own collection 77 + if (recipientUserId.equals(actorUserId)) { 78 + return err( 79 + new NotificationServiceError( 80 + 'Cannot notify user about their own action', 81 + ), 82 + ); 83 + } 84 + 85 + const notificationResult = Notification.createUserAddedToYourCollection( 86 + recipientUserId, 87 + actorUserId, 88 + cardId, 89 + collectionId, 90 + ); 91 + 92 + if (notificationResult.isErr()) { 93 + return err( 94 + new NotificationServiceError(notificationResult.error.message), 95 + ); 96 + } 97 + 98 + const notification = notificationResult.value; 99 + const saveResult = await this.notificationRepository.save(notification); 100 + 101 + if (saveResult.isErr()) { 102 + return err( 103 + new NotificationServiceError( 104 + `Failed to save notification: ${saveResult.error.message}`, 105 + ), 106 + ); 107 + } 108 + 109 + return ok(notification); 110 + } catch (error) { 111 + return err( 112 + new NotificationServiceError( 113 + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, 114 + ), 115 + ); 116 + } 117 + } 68 118 }
+4
src/modules/notifications/domain/value-objects/NotificationType.ts
··· 33 33 public static userAddedYourCollection(): Result<NotificationType> { 34 34 return this.create(NotificationTypeEnum.USER_ADDED_YOUR_COLLECTION); 35 35 } 36 + 37 + public static userAddedToYourCollection(): Result<NotificationType> { 38 + return this.create(NotificationTypeEnum.USER_ADDED_TO_YOUR_COLLECTION); 39 + } 36 40 }
+37
src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts
··· 261 261 } 262 262 } 263 263 264 + async findByCard(cardId: string): Promise<Result<Notification[]>> { 265 + try { 266 + const result = await this.db.select().from(notifications); 267 + 268 + // Filter by cardId in metadata 269 + const matchingNotifications: Notification[] = []; 270 + for (const notificationData of result) { 271 + const metadata = notificationData.metadata as any; 272 + if (metadata.cardId === cardId) { 273 + const dto: NotificationDTO = { 274 + id: notificationData.id, 275 + recipientUserId: notificationData.recipientUserId, 276 + actorUserId: notificationData.actorUserId, 277 + type: notificationData.type, 278 + metadata: notificationData.metadata as any, 279 + read: notificationData.read, 280 + createdAt: notificationData.createdAt, 281 + updatedAt: notificationData.updatedAt, 282 + }; 283 + 284 + const domainResult = NotificationMapper.toDomain(dto); 285 + if (domainResult.isErr()) { 286 + return err(domainResult.error); 287 + } 288 + 289 + matchingNotifications.push(domainResult.value); 290 + } 291 + } 292 + 293 + return ok(matchingNotifications); 294 + } catch (error) { 295 + return err(error as Error); 296 + } 297 + } 298 + 264 299 async markAllAsReadForUser(recipientId: CuratorId): Promise<Result<number>> { 265 300 try { 266 301 const result = await this.db ··· 482 517 collectionUri: publishedRecords.uri, 483 518 collectionName: collections.name, 484 519 collectionDescription: collections.description, 520 + collectionAccessType: collections.accessType, 485 521 collectionAuthorId: collections.authorId, 486 522 collectionCardCount: collections.cardCount, 487 523 collectionCreatedAt: collections.createdAt, ··· 523 559 uri: c.collectionUri || undefined, 524 560 name: c.collectionName, 525 561 description: c.collectionDescription || undefined, 562 + accessType: c.collectionAccessType as 'OPEN' | 'CLOSED', 526 563 authorId: c.collectionAuthorId, 527 564 cardCount: c.collectionCardCount, 528 565 createdAt: c.collectionCreatedAt,
+585
src/modules/notifications/tests/application/CreateNotificationUseCase.test.ts
··· 1 + import { CreateNotificationUseCase } from '../../application/useCases/commands/CreateNotificationUseCase'; 2 + import { InMemoryNotificationRepository } from '../infrastructure/InMemoryNotificationRepository'; 3 + import { NotificationService } from '../../domain/services/NotificationService'; 4 + import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 5 + import { CardId } from '../../../cards/domain/value-objects/CardId'; 6 + import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; 7 + import { NotificationId } from '../../domain/value-objects/NotificationId'; 8 + import { NotificationType } from '@semble/types'; 9 + 10 + describe('CreateNotificationUseCase', () => { 11 + let useCase: CreateNotificationUseCase; 12 + let notificationRepository: InMemoryNotificationRepository; 13 + let notificationService: NotificationService; 14 + 15 + // Test data 16 + let recipientId: CuratorId; 17 + let actorId: CuratorId; 18 + let cardId: CardId; 19 + let collectionId: CollectionId; 20 + 21 + beforeEach(() => { 22 + // Initialize repository and services 23 + notificationRepository = InMemoryNotificationRepository.getInstance(); 24 + notificationService = new NotificationService(notificationRepository); 25 + useCase = new CreateNotificationUseCase(notificationService); 26 + 27 + // Create test data 28 + recipientId = CuratorId.create('did:plc:recipient123').unwrap(); 29 + actorId = CuratorId.create('did:plc:actor456').unwrap(); 30 + cardId = CardId.createFromString('test-card-id-789').unwrap(); 31 + collectionId = CollectionId.createFromString( 32 + 'test-collection-id-abc', 33 + ).unwrap(); 34 + }); 35 + 36 + afterEach(() => { 37 + // Clean up repository between tests 38 + InMemoryNotificationRepository.resetInstance(); 39 + }); 40 + 41 + describe('USER_ADDED_YOUR_CARD notifications', () => { 42 + it('should successfully create notification with required fields only', async () => { 43 + const request = { 44 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 45 + recipientUserId: recipientId.value, 46 + actorUserId: actorId.value, 47 + cardId: cardId.getStringValue(), 48 + }; 49 + 50 + const result = await useCase.execute(request); 51 + 52 + expect(result.isOk()).toBe(true); 53 + const response = result.unwrap(); 54 + expect(response.notificationId).toBeDefined(); 55 + expect(typeof response.notificationId).toBe('string'); 56 + }); 57 + 58 + it('should successfully create notification with collection IDs', async () => { 59 + const collection2Id = CollectionId.createFromString( 60 + 'test-collection-id-2', 61 + ).unwrap(); 62 + 63 + const request = { 64 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 65 + recipientUserId: recipientId.value, 66 + actorUserId: actorId.value, 67 + cardId: cardId.getStringValue(), 68 + collectionIds: [ 69 + collectionId.getStringValue(), 70 + collection2Id.getStringValue(), 71 + ], 72 + }; 73 + 74 + const result = await useCase.execute(request); 75 + 76 + expect(result.isOk()).toBe(true); 77 + const response = result.unwrap(); 78 + expect(response.notificationId).toBeDefined(); 79 + }); 80 + 81 + it('should save notification to repository', async () => { 82 + const request = { 83 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 84 + recipientUserId: recipientId.value, 85 + actorUserId: actorId.value, 86 + cardId: cardId.getStringValue(), 87 + }; 88 + 89 + const result = await useCase.execute(request); 90 + expect(result.isOk()).toBe(true); 91 + 92 + const response = result.unwrap(); 93 + 94 + // Verify notification was saved 95 + const notificationId = NotificationId.createFromString( 96 + response.notificationId, 97 + ).unwrap(); 98 + const savedNotification = 99 + await notificationRepository.findById(notificationId); 100 + 101 + expect(savedNotification.isOk()).toBe(true); 102 + expect(savedNotification.unwrap()).toBeDefined(); 103 + 104 + const notification = savedNotification.unwrap(); 105 + expect(notification?.recipientUserId.value).toBe(recipientId.value); 106 + expect(notification?.actorUserId.value).toBe(actorId.value); 107 + expect(notification?.metadata.cardId).toBe(cardId.getStringValue()); 108 + expect(notification?.type.value).toBe( 109 + NotificationType.USER_ADDED_YOUR_CARD, 110 + ); 111 + expect(notification?.read).toBe(false); 112 + }); 113 + 114 + it('should store collection IDs in metadata when provided', async () => { 115 + const request = { 116 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 117 + recipientUserId: recipientId.value, 118 + actorUserId: actorId.value, 119 + cardId: cardId.getStringValue(), 120 + collectionIds: [collectionId.getStringValue()], 121 + }; 122 + 123 + const result = await useCase.execute(request); 124 + expect(result.isOk()).toBe(true); 125 + 126 + const response = result.unwrap(); 127 + const notificationId = NotificationId.createFromString( 128 + response.notificationId, 129 + ).unwrap(); 130 + const savedNotification = 131 + await notificationRepository.findById(notificationId); 132 + 133 + const notification = savedNotification.unwrap(); 134 + expect(notification?.metadata.collectionIds).toEqual([ 135 + collectionId.getStringValue(), 136 + ]); 137 + }); 138 + 139 + it('should handle empty collection IDs array', async () => { 140 + const request = { 141 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 142 + recipientUserId: recipientId.value, 143 + actorUserId: actorId.value, 144 + cardId: cardId.getStringValue(), 145 + collectionIds: [], 146 + }; 147 + 148 + const result = await useCase.execute(request); 149 + 150 + expect(result.isOk()).toBe(true); 151 + }); 152 + 153 + it('should be retrievable via findByRecipient', async () => { 154 + const request = { 155 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 156 + recipientUserId: recipientId.value, 157 + actorUserId: actorId.value, 158 + cardId: cardId.getStringValue(), 159 + }; 160 + 161 + await useCase.execute(request); 162 + 163 + // Retrieve notifications for recipient 164 + const notifications = await notificationRepository.findByRecipient( 165 + recipientId, 166 + { page: 1, limit: 10, unreadOnly: false }, 167 + ); 168 + 169 + expect(notifications.isOk()).toBe(true); 170 + const data = notifications.unwrap(); 171 + expect(data.notifications).toHaveLength(1); 172 + expect(data.notifications[0]?.type.value).toBe( 173 + NotificationType.USER_ADDED_YOUR_CARD, 174 + ); 175 + }); 176 + }); 177 + 178 + describe('USER_ADDED_TO_YOUR_COLLECTION notifications', () => { 179 + it('should successfully create notification', async () => { 180 + const request = { 181 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 182 + recipientUserId: recipientId.value, 183 + actorUserId: actorId.value, 184 + cardId: cardId.getStringValue(), 185 + collectionId: collectionId.getStringValue(), 186 + }; 187 + 188 + const result = await useCase.execute(request); 189 + 190 + expect(result.isOk()).toBe(true); 191 + const response = result.unwrap(); 192 + expect(response.notificationId).toBeDefined(); 193 + }); 194 + 195 + it('should save notification with correct type', async () => { 196 + const request = { 197 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 198 + recipientUserId: recipientId.value, 199 + actorUserId: actorId.value, 200 + cardId: cardId.getStringValue(), 201 + collectionId: collectionId.getStringValue(), 202 + }; 203 + 204 + const result = await useCase.execute(request); 205 + expect(result.isOk()).toBe(true); 206 + 207 + const response = result.unwrap(); 208 + const notificationId = NotificationId.createFromString( 209 + response.notificationId, 210 + ).unwrap(); 211 + const savedNotification = 212 + await notificationRepository.findById(notificationId); 213 + 214 + const notification = savedNotification.unwrap(); 215 + expect(notification?.type.value).toBe( 216 + NotificationType.USER_ADDED_TO_YOUR_COLLECTION, 217 + ); 218 + expect(notification?.recipientUserId.value).toBe(recipientId.value); 219 + expect(notification?.actorUserId.value).toBe(actorId.value); 220 + }); 221 + 222 + it('should store single collection ID in metadata array', async () => { 223 + const request = { 224 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 225 + recipientUserId: recipientId.value, 226 + actorUserId: actorId.value, 227 + cardId: cardId.getStringValue(), 228 + collectionId: collectionId.getStringValue(), 229 + }; 230 + 231 + const result = await useCase.execute(request); 232 + expect(result.isOk()).toBe(true); 233 + 234 + const response = result.unwrap(); 235 + const notificationId = NotificationId.createFromString( 236 + response.notificationId, 237 + ).unwrap(); 238 + const savedNotification = 239 + await notificationRepository.findById(notificationId); 240 + 241 + const notification = savedNotification.unwrap(); 242 + expect(notification?.metadata.cardId).toBe(cardId.getStringValue()); 243 + expect(notification?.metadata.collectionIds).toEqual([ 244 + collectionId.getStringValue(), 245 + ]); 246 + }); 247 + 248 + it('should be retrievable via findByRecipient', async () => { 249 + const request = { 250 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 251 + recipientUserId: recipientId.value, 252 + actorUserId: actorId.value, 253 + cardId: cardId.getStringValue(), 254 + collectionId: collectionId.getStringValue(), 255 + }; 256 + 257 + await useCase.execute(request); 258 + 259 + const notifications = await notificationRepository.findByRecipient( 260 + recipientId, 261 + { page: 1, limit: 10, unreadOnly: false }, 262 + ); 263 + 264 + expect(notifications.isOk()).toBe(true); 265 + const data = notifications.unwrap(); 266 + expect(data.notifications).toHaveLength(1); 267 + expect(data.notifications[0]?.type.value).toBe( 268 + NotificationType.USER_ADDED_TO_YOUR_COLLECTION, 269 + ); 270 + }); 271 + }); 272 + 273 + describe('Validation', () => { 274 + describe('Invalid recipient ID', () => { 275 + it('should fail with empty recipient ID', async () => { 276 + const request = { 277 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 278 + recipientUserId: '', 279 + actorUserId: actorId.value, 280 + cardId: cardId.getStringValue(), 281 + }; 282 + 283 + const result = await useCase.execute(request); 284 + 285 + expect(result.isErr()).toBe(true); 286 + if (result.isErr()) { 287 + expect(result.error.message).toContain('Invalid recipient ID'); 288 + } 289 + }); 290 + 291 + it('should fail with malformed recipient DID', async () => { 292 + const request = { 293 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 294 + recipientUserId: 'not-a-did', 295 + actorUserId: actorId.value, 296 + cardId: cardId.getStringValue(), 297 + }; 298 + 299 + const result = await useCase.execute(request); 300 + 301 + expect(result.isErr()).toBe(true); 302 + if (result.isErr()) { 303 + expect(result.error.message).toContain('Invalid recipient ID'); 304 + } 305 + }); 306 + }); 307 + 308 + describe('Invalid actor ID', () => { 309 + it('should fail with empty actor ID', async () => { 310 + const request = { 311 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 312 + recipientUserId: recipientId.value, 313 + actorUserId: '', 314 + cardId: cardId.getStringValue(), 315 + }; 316 + 317 + const result = await useCase.execute(request); 318 + 319 + expect(result.isErr()).toBe(true); 320 + if (result.isErr()) { 321 + expect(result.error.message).toContain('Invalid actor ID'); 322 + } 323 + }); 324 + 325 + it('should fail with malformed actor DID', async () => { 326 + const request = { 327 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 328 + recipientUserId: recipientId.value, 329 + actorUserId: 'invalid-did-format', 330 + cardId: cardId.getStringValue(), 331 + }; 332 + 333 + const result = await useCase.execute(request); 334 + 335 + expect(result.isErr()).toBe(true); 336 + if (result.isErr()) { 337 + expect(result.error.message).toContain('Invalid actor ID'); 338 + } 339 + }); 340 + }); 341 + 342 + describe('Invalid card ID', () => { 343 + it('should accept empty card ID (CardId validation allows it)', async () => { 344 + // Note: CardId.createFromString only checks null/undefined, not empty strings 345 + const request = { 346 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 347 + recipientUserId: recipientId.value, 348 + actorUserId: actorId.value, 349 + cardId: '', 350 + }; 351 + 352 + const result = await useCase.execute(request); 353 + 354 + // CardId accepts empty strings, so this succeeds 355 + expect(result.isOk()).toBe(true); 356 + }); 357 + }); 358 + 359 + describe('Invalid collection IDs (USER_ADDED_YOUR_CARD)', () => { 360 + it('should accept empty collection ID in array (CollectionId validation allows it)', async () => { 361 + // Note: CollectionId.createFromString only checks null/undefined, not empty strings 362 + const request = { 363 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 364 + recipientUserId: recipientId.value, 365 + actorUserId: actorId.value, 366 + cardId: cardId.getStringValue(), 367 + collectionIds: ['valid-id', ''], // Empty string is technically valid 368 + }; 369 + 370 + const result = await useCase.execute(request); 371 + 372 + // CollectionId accepts empty strings, so this succeeds 373 + expect(result.isOk()).toBe(true); 374 + }); 375 + }); 376 + 377 + describe('Invalid collection ID (USER_ADDED_TO_YOUR_COLLECTION)', () => { 378 + it('should accept empty collection ID (CollectionId validation allows it)', async () => { 379 + // Note: CollectionId.createFromString only checks null/undefined, not empty strings 380 + const request = { 381 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 382 + recipientUserId: recipientId.value, 383 + actorUserId: actorId.value, 384 + cardId: cardId.getStringValue(), 385 + collectionId: '', 386 + }; 387 + 388 + const result = await useCase.execute(request); 389 + 390 + // CollectionId accepts empty strings, so this succeeds 391 + expect(result.isOk()).toBe(true); 392 + }); 393 + }); 394 + }); 395 + 396 + describe('Business rules', () => { 397 + describe('Self-notification prevention', () => { 398 + it('should not create USER_ADDED_YOUR_CARD notification when recipient equals actor', async () => { 399 + const samePerson = CuratorId.create('did:plc:sameperson').unwrap(); 400 + 401 + const request = { 402 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 403 + recipientUserId: samePerson.value, 404 + actorUserId: samePerson.value, 405 + cardId: cardId.getStringValue(), 406 + }; 407 + 408 + const result = await useCase.execute(request); 409 + 410 + expect(result.isErr()).toBe(true); 411 + if (result.isErr()) { 412 + expect(result.error.message).toContain( 413 + 'Cannot notify user about their own action', 414 + ); 415 + } 416 + }); 417 + 418 + it('should not create USER_ADDED_TO_YOUR_COLLECTION notification when recipient equals actor', async () => { 419 + const samePerson = CuratorId.create('did:plc:sameperson').unwrap(); 420 + 421 + const request = { 422 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 423 + recipientUserId: samePerson.value, 424 + actorUserId: samePerson.value, 425 + cardId: cardId.getStringValue(), 426 + collectionId: collectionId.getStringValue(), 427 + }; 428 + 429 + const result = await useCase.execute(request); 430 + 431 + expect(result.isErr()).toBe(true); 432 + if (result.isErr()) { 433 + expect(result.error.message).toContain( 434 + 'Cannot notify user about their own action', 435 + ); 436 + } 437 + }); 438 + 439 + it('should create notification when recipient and actor are different', async () => { 440 + const request = { 441 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 442 + recipientUserId: recipientId.value, 443 + actorUserId: actorId.value, 444 + cardId: cardId.getStringValue(), 445 + }; 446 + 447 + const result = await useCase.execute(request); 448 + 449 + expect(result.isOk()).toBe(true); 450 + }); 451 + }); 452 + }); 453 + 454 + describe('Multiple notifications', () => { 455 + it('should create multiple notifications for same recipient', async () => { 456 + const card1 = CardId.createFromString('card-1').unwrap(); 457 + const card2 = CardId.createFromString('card-2').unwrap(); 458 + 459 + const request1 = { 460 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 461 + recipientUserId: recipientId.value, 462 + actorUserId: actorId.value, 463 + cardId: card1.getStringValue(), 464 + }; 465 + 466 + const request2 = { 467 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 468 + recipientUserId: recipientId.value, 469 + actorUserId: actorId.value, 470 + cardId: card2.getStringValue(), 471 + }; 472 + 473 + const result1 = await useCase.execute(request1); 474 + const result2 = await useCase.execute(request2); 475 + 476 + expect(result1.isOk()).toBe(true); 477 + expect(result2.isOk()).toBe(true); 478 + 479 + // Verify both notifications exist 480 + const notifications = await notificationRepository.findByRecipient( 481 + recipientId, 482 + { page: 1, limit: 10, unreadOnly: false }, 483 + ); 484 + 485 + expect(notifications.isOk()).toBe(true); 486 + const data = notifications.unwrap(); 487 + expect(data.notifications).toHaveLength(2); 488 + expect(data.totalCount).toBe(2); 489 + }); 490 + 491 + it('should create different notification types for same recipient', async () => { 492 + const request1 = { 493 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 494 + recipientUserId: recipientId.value, 495 + actorUserId: actorId.value, 496 + cardId: cardId.getStringValue(), 497 + }; 498 + 499 + const request2 = { 500 + type: NotificationType.USER_ADDED_TO_YOUR_COLLECTION as const, 501 + recipientUserId: recipientId.value, 502 + actorUserId: actorId.value, 503 + cardId: cardId.getStringValue(), 504 + collectionId: collectionId.getStringValue(), 505 + }; 506 + 507 + const result1 = await useCase.execute(request1); 508 + const result2 = await useCase.execute(request2); 509 + 510 + expect(result1.isOk()).toBe(true); 511 + expect(result2.isOk()).toBe(true); 512 + 513 + const notifications = await notificationRepository.findByRecipient( 514 + recipientId, 515 + { page: 1, limit: 10, unreadOnly: false }, 516 + ); 517 + 518 + expect(notifications.isOk()).toBe(true); 519 + const data = notifications.unwrap(); 520 + expect(data.notifications).toHaveLength(2); 521 + 522 + // Verify different types 523 + const types = data.notifications.map((n) => n.type.value).sort(); 524 + expect(types).toEqual([ 525 + NotificationType.USER_ADDED_TO_YOUR_COLLECTION, 526 + NotificationType.USER_ADDED_YOUR_CARD, 527 + ]); 528 + }); 529 + }); 530 + 531 + describe('Repository integration', () => { 532 + it('should return same notification ID on repeated findById calls', async () => { 533 + const request = { 534 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 535 + recipientUserId: recipientId.value, 536 + actorUserId: actorId.value, 537 + cardId: cardId.getStringValue(), 538 + }; 539 + 540 + const result = await useCase.execute(request); 541 + expect(result.isOk()).toBe(true); 542 + 543 + const response = result.unwrap(); 544 + const notificationId = NotificationId.createFromString( 545 + response.notificationId, 546 + ).unwrap(); 547 + 548 + // Find multiple times 549 + const found1 = await notificationRepository.findById(notificationId); 550 + const found2 = await notificationRepository.findById(notificationId); 551 + 552 + expect(found1.unwrap()?.notificationId.getValue()).toEqual( 553 + found2.unwrap()?.notificationId.getValue(), 554 + ); 555 + }); 556 + 557 + it('should handle clean repository state after reset', async () => { 558 + const request = { 559 + type: NotificationType.USER_ADDED_YOUR_CARD as const, 560 + recipientUserId: recipientId.value, 561 + actorUserId: actorId.value, 562 + cardId: cardId.getStringValue(), 563 + }; 564 + 565 + await useCase.execute(request); 566 + 567 + // Reset repository 568 + InMemoryNotificationRepository.resetInstance(); 569 + notificationRepository = InMemoryNotificationRepository.getInstance(); 570 + notificationService = new NotificationService(notificationRepository); 571 + useCase = new CreateNotificationUseCase(notificationService); 572 + 573 + // Verify repository is clean 574 + const notifications = await notificationRepository.findByRecipient( 575 + recipientId, 576 + { page: 1, limit: 10, unreadOnly: false }, 577 + ); 578 + 579 + expect(notifications.isOk()).toBe(true); 580 + const data = notifications.unwrap(); 581 + expect(data.notifications).toHaveLength(0); 582 + expect(data.totalCount).toBe(0); 583 + }); 584 + }); 585 + });
+9
src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts
··· 129 129 return ok(matchingNotifications); 130 130 } 131 131 132 + async findByCard(cardId: string): Promise<Result<Notification[]>> { 133 + const matchingNotifications = Array.from( 134 + this.notifications.values(), 135 + ).filter((notification) => notification.metadata.cardId === cardId); 136 + 137 + return ok(matchingNotifications); 138 + } 139 + 132 140 async markAllAsReadForUser(recipientId: CuratorId): Promise<Result<number>> { 133 141 let markedCount = 0; 134 142 ··· 238 246 uri: `at://${c.authorId}/network.cosmik.local.collection/${c.id}`, 239 247 name: c.name, 240 248 description: undefined, // Not in collection result 249 + accessType: c.accessType, 241 250 authorId: c.authorId, 242 251 cardCount: 0, // Not in collection result 243 252 createdAt: new Date(), // Not in collection result
+1
src/shared/constants/atproto.ts
··· 14 14 CARD: 'network.cosmik.card', 15 15 COLLECTION: 'network.cosmik.collection', 16 16 COLLECTION_LINK: 'network.cosmik.collectionLink', 17 + COLLECTION_LINK_REMOVAL: 'network.cosmik.collectionLinkRemoval', 17 18 }, 18 19 } as const;
+5
src/shared/infrastructure/config/EnvironmentConfigService.ts
··· 35 35 marginBookmark: string; 36 36 marginCollection: string; 37 37 marginCollectionItem: string; 38 + collectionLinkRemoval: string; 38 39 }; 39 40 serviceAccount: { 40 41 identifier: string; ··· 123 124 environment === Environment.PROD 124 125 ? ATPROTO_NSID.COSMIK.COLLECTION_LINK 125 126 : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.collectionLink`, 127 + collectionLinkRemoval: 128 + environment === Environment.PROD 129 + ? ATPROTO_NSID.COSMIK.COLLECTION_LINK_REMOVAL 130 + : `${ATPROTO_NSID.COSMIK.NAMESPACE}.${environment}.collectionLinkRemoval`, 126 131 // Margin collections - no environment suffix 127 132 marginBookmark: ATPROTO_NSID.MARGIN.BOOKMARK, 128 133 marginCollection: ATPROTO_NSID.MARGIN.COLLECTION,
+1
src/shared/infrastructure/database/migrations/0015_small_loki.sql
··· 1 + CREATE INDEX "idx_collection_cards_added_by_added_at" ON "collection_cards" USING btree ("added_by","added_at" DESC NULLS LAST);
+1410
src/shared/infrastructure/database/migrations/meta/0015_snapshot.json
··· 1 + { 2 + "id": "a6374d24-e08b-4e73-9589-82060d95ba81", 3 + "prevId": "1400606c-d063-46eb-aa12-e7d3ea29b75b", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "url_type": { 87 + "name": "url_type", 88 + "type": "text", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "parent_card_id": { 93 + "name": "parent_card_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "via_card_id": { 99 + "name": "via_card_id", 100 + "type": "uuid", 101 + "primaryKey": false, 102 + "notNull": false 103 + }, 104 + "published_record_id": { 105 + "name": "published_record_id", 106 + "type": "uuid", 107 + "primaryKey": false, 108 + "notNull": false 109 + }, 110 + "library_count": { 111 + "name": "library_count", 112 + "type": "integer", 113 + "primaryKey": false, 114 + "notNull": true, 115 + "default": 0 116 + }, 117 + "created_at": { 118 + "name": "created_at", 119 + "type": "timestamp", 120 + "primaryKey": false, 121 + "notNull": true, 122 + "default": "now()" 123 + }, 124 + "updated_at": { 125 + "name": "updated_at", 126 + "type": "timestamp", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "default": "now()" 130 + } 131 + }, 132 + "indexes": { 133 + "cards_author_url_idx": { 134 + "name": "cards_author_url_idx", 135 + "columns": [ 136 + { 137 + "expression": "author_id", 138 + "isExpression": false, 139 + "asc": true, 140 + "nulls": "last" 141 + }, 142 + { 143 + "expression": "url", 144 + "isExpression": false, 145 + "asc": true, 146 + "nulls": "last" 147 + } 148 + ], 149 + "isUnique": false, 150 + "concurrently": false, 151 + "method": "btree", 152 + "with": {} 153 + }, 154 + "cards_author_id_idx": { 155 + "name": "cards_author_id_idx", 156 + "columns": [ 157 + { 158 + "expression": "author_id", 159 + "isExpression": false, 160 + "asc": true, 161 + "nulls": "last" 162 + } 163 + ], 164 + "isUnique": false, 165 + "concurrently": false, 166 + "method": "btree", 167 + "with": {} 168 + }, 169 + "idx_cards_type_updated_at": { 170 + "name": "idx_cards_type_updated_at", 171 + "columns": [ 172 + { 173 + "expression": "type", 174 + "isExpression": false, 175 + "asc": true, 176 + "nulls": "last" 177 + }, 178 + { 179 + "expression": "updated_at", 180 + "isExpression": false, 181 + "asc": false, 182 + "nulls": "last" 183 + } 184 + ], 185 + "isUnique": false, 186 + "concurrently": false, 187 + "method": "btree", 188 + "with": {} 189 + }, 190 + "idx_cards_url_type": { 191 + "name": "idx_cards_url_type", 192 + "columns": [ 193 + { 194 + "expression": "url", 195 + "isExpression": false, 196 + "asc": true, 197 + "nulls": "last" 198 + }, 199 + { 200 + "expression": "type", 201 + "isExpression": false, 202 + "asc": true, 203 + "nulls": "last" 204 + } 205 + ], 206 + "isUnique": false, 207 + "concurrently": false, 208 + "method": "btree", 209 + "with": {} 210 + }, 211 + "idx_cards_url_type_filter": { 212 + "name": "idx_cards_url_type_filter", 213 + "columns": [ 214 + { 215 + "expression": "url_type", 216 + "isExpression": false, 217 + "asc": true, 218 + "nulls": "last" 219 + } 220 + ], 221 + "isUnique": false, 222 + "concurrently": false, 223 + "method": "btree", 224 + "with": {} 225 + }, 226 + "idx_cards_parent_type": { 227 + "name": "idx_cards_parent_type", 228 + "columns": [ 229 + { 230 + "expression": "parent_card_id", 231 + "isExpression": false, 232 + "asc": true, 233 + "nulls": "last" 234 + }, 235 + { 236 + "expression": "type", 237 + "isExpression": false, 238 + "asc": true, 239 + "nulls": "last" 240 + } 241 + ], 242 + "isUnique": false, 243 + "where": "type = 'NOTE'", 244 + "concurrently": false, 245 + "method": "btree", 246 + "with": {} 247 + } 248 + }, 249 + "foreignKeys": { 250 + "cards_parent_card_id_cards_id_fk": { 251 + "name": "cards_parent_card_id_cards_id_fk", 252 + "tableFrom": "cards", 253 + "tableTo": "cards", 254 + "columnsFrom": ["parent_card_id"], 255 + "columnsTo": ["id"], 256 + "onDelete": "no action", 257 + "onUpdate": "no action" 258 + }, 259 + "cards_via_card_id_cards_id_fk": { 260 + "name": "cards_via_card_id_cards_id_fk", 261 + "tableFrom": "cards", 262 + "tableTo": "cards", 263 + "columnsFrom": ["via_card_id"], 264 + "columnsTo": ["id"], 265 + "onDelete": "no action", 266 + "onUpdate": "no action" 267 + }, 268 + "cards_published_record_id_published_records_id_fk": { 269 + "name": "cards_published_record_id_published_records_id_fk", 270 + "tableFrom": "cards", 271 + "tableTo": "published_records", 272 + "columnsFrom": ["published_record_id"], 273 + "columnsTo": ["id"], 274 + "onDelete": "no action", 275 + "onUpdate": "no action" 276 + } 277 + }, 278 + "compositePrimaryKeys": {}, 279 + "uniqueConstraints": {}, 280 + "policies": {}, 281 + "checkConstraints": {}, 282 + "isRLSEnabled": false 283 + }, 284 + "public.collection_cards": { 285 + "name": "collection_cards", 286 + "schema": "", 287 + "columns": { 288 + "id": { 289 + "name": "id", 290 + "type": "uuid", 291 + "primaryKey": true, 292 + "notNull": true 293 + }, 294 + "collection_id": { 295 + "name": "collection_id", 296 + "type": "uuid", 297 + "primaryKey": false, 298 + "notNull": true 299 + }, 300 + "card_id": { 301 + "name": "card_id", 302 + "type": "uuid", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "added_by": { 307 + "name": "added_by", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "added_at": { 313 + "name": "added_at", 314 + "type": "timestamp", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "default": "now()" 318 + }, 319 + "via_card_id": { 320 + "name": "via_card_id", 321 + "type": "uuid", 322 + "primaryKey": false, 323 + "notNull": false 324 + }, 325 + "published_record_id": { 326 + "name": "published_record_id", 327 + "type": "uuid", 328 + "primaryKey": false, 329 + "notNull": false 330 + } 331 + }, 332 + "indexes": { 333 + "collection_cards_card_id_idx": { 334 + "name": "collection_cards_card_id_idx", 335 + "columns": [ 336 + { 337 + "expression": "card_id", 338 + "isExpression": false, 339 + "asc": true, 340 + "nulls": "last" 341 + } 342 + ], 343 + "isUnique": false, 344 + "concurrently": false, 345 + "method": "btree", 346 + "with": {} 347 + }, 348 + "collection_cards_collection_id_idx": { 349 + "name": "collection_cards_collection_id_idx", 350 + "columns": [ 351 + { 352 + "expression": "collection_id", 353 + "isExpression": false, 354 + "asc": true, 355 + "nulls": "last" 356 + } 357 + ], 358 + "isUnique": false, 359 + "concurrently": false, 360 + "method": "btree", 361 + "with": {} 362 + }, 363 + "idx_collection_cards_collection_added": { 364 + "name": "idx_collection_cards_collection_added", 365 + "columns": [ 366 + { 367 + "expression": "collection_id", 368 + "isExpression": false, 369 + "asc": true, 370 + "nulls": "last" 371 + }, 372 + { 373 + "expression": "added_at", 374 + "isExpression": false, 375 + "asc": false, 376 + "nulls": "last" 377 + } 378 + ], 379 + "isUnique": false, 380 + "concurrently": false, 381 + "method": "btree", 382 + "with": {} 383 + }, 384 + "idx_collection_cards_card_collection": { 385 + "name": "idx_collection_cards_card_collection", 386 + "columns": [ 387 + { 388 + "expression": "card_id", 389 + "isExpression": false, 390 + "asc": true, 391 + "nulls": "last" 392 + } 393 + ], 394 + "isUnique": false, 395 + "concurrently": false, 396 + "method": "btree", 397 + "with": {} 398 + }, 399 + "idx_collection_cards_added_by_added_at": { 400 + "name": "idx_collection_cards_added_by_added_at", 401 + "columns": [ 402 + { 403 + "expression": "added_by", 404 + "isExpression": false, 405 + "asc": true, 406 + "nulls": "last" 407 + }, 408 + { 409 + "expression": "added_at", 410 + "isExpression": false, 411 + "asc": false, 412 + "nulls": "last" 413 + } 414 + ], 415 + "isUnique": false, 416 + "concurrently": false, 417 + "method": "btree", 418 + "with": {} 419 + } 420 + }, 421 + "foreignKeys": { 422 + "collection_cards_collection_id_collections_id_fk": { 423 + "name": "collection_cards_collection_id_collections_id_fk", 424 + "tableFrom": "collection_cards", 425 + "tableTo": "collections", 426 + "columnsFrom": ["collection_id"], 427 + "columnsTo": ["id"], 428 + "onDelete": "cascade", 429 + "onUpdate": "no action" 430 + }, 431 + "collection_cards_card_id_cards_id_fk": { 432 + "name": "collection_cards_card_id_cards_id_fk", 433 + "tableFrom": "collection_cards", 434 + "tableTo": "cards", 435 + "columnsFrom": ["card_id"], 436 + "columnsTo": ["id"], 437 + "onDelete": "cascade", 438 + "onUpdate": "no action" 439 + }, 440 + "collection_cards_via_card_id_cards_id_fk": { 441 + "name": "collection_cards_via_card_id_cards_id_fk", 442 + "tableFrom": "collection_cards", 443 + "tableTo": "cards", 444 + "columnsFrom": ["via_card_id"], 445 + "columnsTo": ["id"], 446 + "onDelete": "no action", 447 + "onUpdate": "no action" 448 + }, 449 + "collection_cards_published_record_id_published_records_id_fk": { 450 + "name": "collection_cards_published_record_id_published_records_id_fk", 451 + "tableFrom": "collection_cards", 452 + "tableTo": "published_records", 453 + "columnsFrom": ["published_record_id"], 454 + "columnsTo": ["id"], 455 + "onDelete": "no action", 456 + "onUpdate": "no action" 457 + } 458 + }, 459 + "compositePrimaryKeys": {}, 460 + "uniqueConstraints": {}, 461 + "policies": {}, 462 + "checkConstraints": {}, 463 + "isRLSEnabled": false 464 + }, 465 + "public.collection_collaborators": { 466 + "name": "collection_collaborators", 467 + "schema": "", 468 + "columns": { 469 + "id": { 470 + "name": "id", 471 + "type": "uuid", 472 + "primaryKey": true, 473 + "notNull": true 474 + }, 475 + "collection_id": { 476 + "name": "collection_id", 477 + "type": "uuid", 478 + "primaryKey": false, 479 + "notNull": true 480 + }, 481 + "collaborator_id": { 482 + "name": "collaborator_id", 483 + "type": "text", 484 + "primaryKey": false, 485 + "notNull": true 486 + } 487 + }, 488 + "indexes": {}, 489 + "foreignKeys": { 490 + "collection_collaborators_collection_id_collections_id_fk": { 491 + "name": "collection_collaborators_collection_id_collections_id_fk", 492 + "tableFrom": "collection_collaborators", 493 + "tableTo": "collections", 494 + "columnsFrom": ["collection_id"], 495 + "columnsTo": ["id"], 496 + "onDelete": "cascade", 497 + "onUpdate": "no action" 498 + } 499 + }, 500 + "compositePrimaryKeys": {}, 501 + "uniqueConstraints": {}, 502 + "policies": {}, 503 + "checkConstraints": {}, 504 + "isRLSEnabled": false 505 + }, 506 + "public.collections": { 507 + "name": "collections", 508 + "schema": "", 509 + "columns": { 510 + "id": { 511 + "name": "id", 512 + "type": "uuid", 513 + "primaryKey": true, 514 + "notNull": true 515 + }, 516 + "author_id": { 517 + "name": "author_id", 518 + "type": "text", 519 + "primaryKey": false, 520 + "notNull": true 521 + }, 522 + "name": { 523 + "name": "name", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true 527 + }, 528 + "description": { 529 + "name": "description", 530 + "type": "text", 531 + "primaryKey": false, 532 + "notNull": false 533 + }, 534 + "access_type": { 535 + "name": "access_type", 536 + "type": "text", 537 + "primaryKey": false, 538 + "notNull": true 539 + }, 540 + "card_count": { 541 + "name": "card_count", 542 + "type": "integer", 543 + "primaryKey": false, 544 + "notNull": true, 545 + "default": 0 546 + }, 547 + "created_at": { 548 + "name": "created_at", 549 + "type": "timestamp", 550 + "primaryKey": false, 551 + "notNull": true, 552 + "default": "now()" 553 + }, 554 + "updated_at": { 555 + "name": "updated_at", 556 + "type": "timestamp", 557 + "primaryKey": false, 558 + "notNull": true, 559 + "default": "now()" 560 + }, 561 + "published_record_id": { 562 + "name": "published_record_id", 563 + "type": "uuid", 564 + "primaryKey": false, 565 + "notNull": false 566 + } 567 + }, 568 + "indexes": { 569 + "collections_author_id_idx": { 570 + "name": "collections_author_id_idx", 571 + "columns": [ 572 + { 573 + "expression": "author_id", 574 + "isExpression": false, 575 + "asc": true, 576 + "nulls": "last" 577 + } 578 + ], 579 + "isUnique": false, 580 + "concurrently": false, 581 + "method": "btree", 582 + "with": {} 583 + }, 584 + "collections_author_updated_at_idx": { 585 + "name": "collections_author_updated_at_idx", 586 + "columns": [ 587 + { 588 + "expression": "author_id", 589 + "isExpression": false, 590 + "asc": true, 591 + "nulls": "last" 592 + }, 593 + { 594 + "expression": "updated_at", 595 + "isExpression": false, 596 + "asc": true, 597 + "nulls": "last" 598 + } 599 + ], 600 + "isUnique": false, 601 + "concurrently": false, 602 + "method": "btree", 603 + "with": {} 604 + } 605 + }, 606 + "foreignKeys": { 607 + "collections_published_record_id_published_records_id_fk": { 608 + "name": "collections_published_record_id_published_records_id_fk", 609 + "tableFrom": "collections", 610 + "tableTo": "published_records", 611 + "columnsFrom": ["published_record_id"], 612 + "columnsTo": ["id"], 613 + "onDelete": "no action", 614 + "onUpdate": "no action" 615 + } 616 + }, 617 + "compositePrimaryKeys": {}, 618 + "uniqueConstraints": {}, 619 + "policies": {}, 620 + "checkConstraints": {}, 621 + "isRLSEnabled": false 622 + }, 623 + "public.library_memberships": { 624 + "name": "library_memberships", 625 + "schema": "", 626 + "columns": { 627 + "card_id": { 628 + "name": "card_id", 629 + "type": "uuid", 630 + "primaryKey": false, 631 + "notNull": true 632 + }, 633 + "user_id": { 634 + "name": "user_id", 635 + "type": "text", 636 + "primaryKey": false, 637 + "notNull": true 638 + }, 639 + "added_at": { 640 + "name": "added_at", 641 + "type": "timestamp", 642 + "primaryKey": false, 643 + "notNull": true, 644 + "default": "now()" 645 + }, 646 + "published_record_id": { 647 + "name": "published_record_id", 648 + "type": "uuid", 649 + "primaryKey": false, 650 + "notNull": false 651 + } 652 + }, 653 + "indexes": { 654 + "idx_user_cards": { 655 + "name": "idx_user_cards", 656 + "columns": [ 657 + { 658 + "expression": "user_id", 659 + "isExpression": false, 660 + "asc": true, 661 + "nulls": "last" 662 + } 663 + ], 664 + "isUnique": false, 665 + "concurrently": false, 666 + "method": "btree", 667 + "with": {} 668 + }, 669 + "idx_card_users": { 670 + "name": "idx_card_users", 671 + "columns": [ 672 + { 673 + "expression": "card_id", 674 + "isExpression": false, 675 + "asc": true, 676 + "nulls": "last" 677 + } 678 + ], 679 + "isUnique": false, 680 + "concurrently": false, 681 + "method": "btree", 682 + "with": {} 683 + }, 684 + "idx_library_memberships_user_type_covering": { 685 + "name": "idx_library_memberships_user_type_covering", 686 + "columns": [ 687 + { 688 + "expression": "user_id", 689 + "isExpression": false, 690 + "asc": true, 691 + "nulls": "last" 692 + }, 693 + { 694 + "expression": "added_at", 695 + "isExpression": false, 696 + "asc": false, 697 + "nulls": "last" 698 + } 699 + ], 700 + "isUnique": false, 701 + "concurrently": false, 702 + "method": "btree", 703 + "with": {} 704 + } 705 + }, 706 + "foreignKeys": { 707 + "library_memberships_card_id_cards_id_fk": { 708 + "name": "library_memberships_card_id_cards_id_fk", 709 + "tableFrom": "library_memberships", 710 + "tableTo": "cards", 711 + "columnsFrom": ["card_id"], 712 + "columnsTo": ["id"], 713 + "onDelete": "cascade", 714 + "onUpdate": "no action" 715 + }, 716 + "library_memberships_published_record_id_published_records_id_fk": { 717 + "name": "library_memberships_published_record_id_published_records_id_fk", 718 + "tableFrom": "library_memberships", 719 + "tableTo": "published_records", 720 + "columnsFrom": ["published_record_id"], 721 + "columnsTo": ["id"], 722 + "onDelete": "no action", 723 + "onUpdate": "no action" 724 + } 725 + }, 726 + "compositePrimaryKeys": { 727 + "library_memberships_card_id_user_id_pk": { 728 + "name": "library_memberships_card_id_user_id_pk", 729 + "columns": ["card_id", "user_id"] 730 + } 731 + }, 732 + "uniqueConstraints": {}, 733 + "policies": {}, 734 + "checkConstraints": {}, 735 + "isRLSEnabled": false 736 + }, 737 + "public.published_records": { 738 + "name": "published_records", 739 + "schema": "", 740 + "columns": { 741 + "id": { 742 + "name": "id", 743 + "type": "uuid", 744 + "primaryKey": true, 745 + "notNull": true 746 + }, 747 + "uri": { 748 + "name": "uri", 749 + "type": "text", 750 + "primaryKey": false, 751 + "notNull": true 752 + }, 753 + "cid": { 754 + "name": "cid", 755 + "type": "text", 756 + "primaryKey": false, 757 + "notNull": true 758 + }, 759 + "recorded_at": { 760 + "name": "recorded_at", 761 + "type": "timestamp", 762 + "primaryKey": false, 763 + "notNull": true, 764 + "default": "now()" 765 + } 766 + }, 767 + "indexes": { 768 + "uri_cid_unique_idx": { 769 + "name": "uri_cid_unique_idx", 770 + "columns": [ 771 + { 772 + "expression": "uri", 773 + "isExpression": false, 774 + "asc": true, 775 + "nulls": "last" 776 + }, 777 + { 778 + "expression": "cid", 779 + "isExpression": false, 780 + "asc": true, 781 + "nulls": "last" 782 + } 783 + ], 784 + "isUnique": true, 785 + "concurrently": false, 786 + "method": "btree", 787 + "with": {} 788 + }, 789 + "published_records_uri_idx": { 790 + "name": "published_records_uri_idx", 791 + "columns": [ 792 + { 793 + "expression": "uri", 794 + "isExpression": false, 795 + "asc": true, 796 + "nulls": "last" 797 + } 798 + ], 799 + "isUnique": false, 800 + "concurrently": false, 801 + "method": "btree", 802 + "with": {} 803 + } 804 + }, 805 + "foreignKeys": {}, 806 + "compositePrimaryKeys": {}, 807 + "uniqueConstraints": {}, 808 + "policies": {}, 809 + "checkConstraints": {}, 810 + "isRLSEnabled": false 811 + }, 812 + "public.feed_activities": { 813 + "name": "feed_activities", 814 + "schema": "", 815 + "columns": { 816 + "id": { 817 + "name": "id", 818 + "type": "uuid", 819 + "primaryKey": true, 820 + "notNull": true 821 + }, 822 + "actor_id": { 823 + "name": "actor_id", 824 + "type": "text", 825 + "primaryKey": false, 826 + "notNull": true 827 + }, 828 + "card_id": { 829 + "name": "card_id", 830 + "type": "text", 831 + "primaryKey": false, 832 + "notNull": false 833 + }, 834 + "type": { 835 + "name": "type", 836 + "type": "text", 837 + "primaryKey": false, 838 + "notNull": true 839 + }, 840 + "metadata": { 841 + "name": "metadata", 842 + "type": "jsonb", 843 + "primaryKey": false, 844 + "notNull": true 845 + }, 846 + "url_type": { 847 + "name": "url_type", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false 851 + }, 852 + "source": { 853 + "name": "source", 854 + "type": "text", 855 + "primaryKey": false, 856 + "notNull": false 857 + }, 858 + "created_at": { 859 + "name": "created_at", 860 + "type": "timestamp", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "default": "now()" 864 + } 865 + }, 866 + "indexes": { 867 + "feed_activities_type_idx": { 868 + "name": "feed_activities_type_idx", 869 + "columns": [ 870 + { 871 + "expression": "type", 872 + "isExpression": false, 873 + "asc": true, 874 + "nulls": "last" 875 + } 876 + ], 877 + "isUnique": false, 878 + "concurrently": false, 879 + "method": "btree", 880 + "with": {} 881 + }, 882 + "feed_activities_url_type_idx": { 883 + "name": "feed_activities_url_type_idx", 884 + "columns": [ 885 + { 886 + "expression": "url_type", 887 + "isExpression": false, 888 + "asc": true, 889 + "nulls": "last" 890 + } 891 + ], 892 + "isUnique": false, 893 + "concurrently": false, 894 + "method": "btree", 895 + "with": {} 896 + }, 897 + "feed_activities_created_at_idx": { 898 + "name": "feed_activities_created_at_idx", 899 + "columns": [ 900 + { 901 + "expression": "created_at", 902 + "isExpression": false, 903 + "asc": false, 904 + "nulls": "last" 905 + } 906 + ], 907 + "isUnique": false, 908 + "concurrently": false, 909 + "method": "btree", 910 + "with": {} 911 + }, 912 + "feed_activities_type_created_at_idx": { 913 + "name": "feed_activities_type_created_at_idx", 914 + "columns": [ 915 + { 916 + "expression": "type", 917 + "isExpression": false, 918 + "asc": true, 919 + "nulls": "last" 920 + }, 921 + { 922 + "expression": "created_at", 923 + "isExpression": false, 924 + "asc": false, 925 + "nulls": "last" 926 + } 927 + ], 928 + "isUnique": false, 929 + "concurrently": false, 930 + "method": "btree", 931 + "with": {} 932 + }, 933 + "feed_activities_url_type_created_at_idx": { 934 + "name": "feed_activities_url_type_created_at_idx", 935 + "columns": [ 936 + { 937 + "expression": "url_type", 938 + "isExpression": false, 939 + "asc": true, 940 + "nulls": "last" 941 + }, 942 + { 943 + "expression": "created_at", 944 + "isExpression": false, 945 + "asc": false, 946 + "nulls": "last" 947 + } 948 + ], 949 + "isUnique": false, 950 + "concurrently": false, 951 + "method": "btree", 952 + "with": {} 953 + }, 954 + "feed_activities_type_url_type_created_at_idx": { 955 + "name": "feed_activities_type_url_type_created_at_idx", 956 + "columns": [ 957 + { 958 + "expression": "type", 959 + "isExpression": false, 960 + "asc": true, 961 + "nulls": "last" 962 + }, 963 + { 964 + "expression": "url_type", 965 + "isExpression": false, 966 + "asc": true, 967 + "nulls": "last" 968 + }, 969 + { 970 + "expression": "created_at", 971 + "isExpression": false, 972 + "asc": false, 973 + "nulls": "last" 974 + } 975 + ], 976 + "isUnique": false, 977 + "concurrently": false, 978 + "method": "btree", 979 + "with": {} 980 + }, 981 + "feed_activities_dedup_idx": { 982 + "name": "feed_activities_dedup_idx", 983 + "columns": [ 984 + { 985 + "expression": "actor_id", 986 + "isExpression": false, 987 + "asc": true, 988 + "nulls": "last" 989 + }, 990 + { 991 + "expression": "card_id", 992 + "isExpression": false, 993 + "asc": true, 994 + "nulls": "last" 995 + }, 996 + { 997 + "expression": "created_at", 998 + "isExpression": false, 999 + "asc": false, 1000 + "nulls": "last" 1001 + } 1002 + ], 1003 + "isUnique": false, 1004 + "concurrently": false, 1005 + "method": "btree", 1006 + "with": {} 1007 + }, 1008 + "feed_activities_card_id_idx": { 1009 + "name": "feed_activities_card_id_idx", 1010 + "columns": [ 1011 + { 1012 + "expression": "card_id", 1013 + "isExpression": false, 1014 + "asc": true, 1015 + "nulls": "last" 1016 + } 1017 + ], 1018 + "isUnique": false, 1019 + "concurrently": false, 1020 + "method": "btree", 1021 + "with": {} 1022 + }, 1023 + "feed_activities_source_idx": { 1024 + "name": "feed_activities_source_idx", 1025 + "columns": [ 1026 + { 1027 + "expression": "source", 1028 + "isExpression": false, 1029 + "asc": true, 1030 + "nulls": "last" 1031 + } 1032 + ], 1033 + "isUnique": false, 1034 + "concurrently": false, 1035 + "method": "btree", 1036 + "with": {} 1037 + } 1038 + }, 1039 + "foreignKeys": {}, 1040 + "compositePrimaryKeys": {}, 1041 + "uniqueConstraints": {}, 1042 + "policies": {}, 1043 + "checkConstraints": {}, 1044 + "isRLSEnabled": false 1045 + }, 1046 + "public.notifications": { 1047 + "name": "notifications", 1048 + "schema": "", 1049 + "columns": { 1050 + "id": { 1051 + "name": "id", 1052 + "type": "uuid", 1053 + "primaryKey": true, 1054 + "notNull": true 1055 + }, 1056 + "recipient_user_id": { 1057 + "name": "recipient_user_id", 1058 + "type": "text", 1059 + "primaryKey": false, 1060 + "notNull": true 1061 + }, 1062 + "actor_user_id": { 1063 + "name": "actor_user_id", 1064 + "type": "text", 1065 + "primaryKey": false, 1066 + "notNull": true 1067 + }, 1068 + "type": { 1069 + "name": "type", 1070 + "type": "text", 1071 + "primaryKey": false, 1072 + "notNull": true 1073 + }, 1074 + "metadata": { 1075 + "name": "metadata", 1076 + "type": "jsonb", 1077 + "primaryKey": false, 1078 + "notNull": true 1079 + }, 1080 + "read": { 1081 + "name": "read", 1082 + "type": "boolean", 1083 + "primaryKey": false, 1084 + "notNull": true, 1085 + "default": false 1086 + }, 1087 + "created_at": { 1088 + "name": "created_at", 1089 + "type": "timestamp", 1090 + "primaryKey": false, 1091 + "notNull": true, 1092 + "default": "now()" 1093 + }, 1094 + "updated_at": { 1095 + "name": "updated_at", 1096 + "type": "timestamp", 1097 + "primaryKey": false, 1098 + "notNull": true, 1099 + "default": "now()" 1100 + } 1101 + }, 1102 + "indexes": { 1103 + "notifications_recipient_idx": { 1104 + "name": "notifications_recipient_idx", 1105 + "columns": [ 1106 + { 1107 + "expression": "recipient_user_id", 1108 + "isExpression": false, 1109 + "asc": true, 1110 + "nulls": "last" 1111 + } 1112 + ], 1113 + "isUnique": false, 1114 + "concurrently": false, 1115 + "method": "btree", 1116 + "with": {} 1117 + }, 1118 + "notifications_recipient_created_at_idx": { 1119 + "name": "notifications_recipient_created_at_idx", 1120 + "columns": [ 1121 + { 1122 + "expression": "recipient_user_id", 1123 + "isExpression": false, 1124 + "asc": true, 1125 + "nulls": "last" 1126 + }, 1127 + { 1128 + "expression": "created_at", 1129 + "isExpression": false, 1130 + "asc": false, 1131 + "nulls": "last" 1132 + } 1133 + ], 1134 + "isUnique": false, 1135 + "concurrently": false, 1136 + "method": "btree", 1137 + "with": {} 1138 + }, 1139 + "notifications_recipient_read_idx": { 1140 + "name": "notifications_recipient_read_idx", 1141 + "columns": [ 1142 + { 1143 + "expression": "recipient_user_id", 1144 + "isExpression": false, 1145 + "asc": true, 1146 + "nulls": "last" 1147 + }, 1148 + { 1149 + "expression": "read", 1150 + "isExpression": false, 1151 + "asc": true, 1152 + "nulls": "last" 1153 + } 1154 + ], 1155 + "isUnique": false, 1156 + "concurrently": false, 1157 + "method": "btree", 1158 + "with": {} 1159 + } 1160 + }, 1161 + "foreignKeys": {}, 1162 + "compositePrimaryKeys": {}, 1163 + "uniqueConstraints": {}, 1164 + "policies": {}, 1165 + "checkConstraints": {}, 1166 + "isRLSEnabled": false 1167 + }, 1168 + "public.sync_statuses": { 1169 + "name": "sync_statuses", 1170 + "schema": "", 1171 + "columns": { 1172 + "id": { 1173 + "name": "id", 1174 + "type": "uuid", 1175 + "primaryKey": true, 1176 + "notNull": true, 1177 + "default": "gen_random_uuid()" 1178 + }, 1179 + "curator_id": { 1180 + "name": "curator_id", 1181 + "type": "text", 1182 + "primaryKey": false, 1183 + "notNull": true 1184 + }, 1185 + "sync_state": { 1186 + "name": "sync_state", 1187 + "type": "text", 1188 + "primaryKey": false, 1189 + "notNull": true 1190 + }, 1191 + "last_synced_at": { 1192 + "name": "last_synced_at", 1193 + "type": "timestamp", 1194 + "primaryKey": false, 1195 + "notNull": false 1196 + }, 1197 + "last_sync_attempt_at": { 1198 + "name": "last_sync_attempt_at", 1199 + "type": "timestamp", 1200 + "primaryKey": false, 1201 + "notNull": false 1202 + }, 1203 + "sync_error_message": { 1204 + "name": "sync_error_message", 1205 + "type": "text", 1206 + "primaryKey": false, 1207 + "notNull": false 1208 + }, 1209 + "records_processed": { 1210 + "name": "records_processed", 1211 + "type": "integer", 1212 + "primaryKey": false, 1213 + "notNull": false 1214 + }, 1215 + "created_at": { 1216 + "name": "created_at", 1217 + "type": "timestamp", 1218 + "primaryKey": false, 1219 + "notNull": true, 1220 + "default": "now()" 1221 + }, 1222 + "updated_at": { 1223 + "name": "updated_at", 1224 + "type": "timestamp", 1225 + "primaryKey": false, 1226 + "notNull": true, 1227 + "default": "now()" 1228 + } 1229 + }, 1230 + "indexes": {}, 1231 + "foreignKeys": {}, 1232 + "compositePrimaryKeys": {}, 1233 + "uniqueConstraints": { 1234 + "sync_statuses_curator_id_unique": { 1235 + "name": "sync_statuses_curator_id_unique", 1236 + "nullsNotDistinct": false, 1237 + "columns": ["curator_id"] 1238 + } 1239 + }, 1240 + "policies": {}, 1241 + "checkConstraints": {}, 1242 + "isRLSEnabled": false 1243 + }, 1244 + "public.auth_session": { 1245 + "name": "auth_session", 1246 + "schema": "", 1247 + "columns": { 1248 + "key": { 1249 + "name": "key", 1250 + "type": "text", 1251 + "primaryKey": true, 1252 + "notNull": true 1253 + }, 1254 + "session": { 1255 + "name": "session", 1256 + "type": "text", 1257 + "primaryKey": false, 1258 + "notNull": true 1259 + } 1260 + }, 1261 + "indexes": {}, 1262 + "foreignKeys": {}, 1263 + "compositePrimaryKeys": {}, 1264 + "uniqueConstraints": {}, 1265 + "policies": {}, 1266 + "checkConstraints": {}, 1267 + "isRLSEnabled": false 1268 + }, 1269 + "public.auth_state": { 1270 + "name": "auth_state", 1271 + "schema": "", 1272 + "columns": { 1273 + "key": { 1274 + "name": "key", 1275 + "type": "text", 1276 + "primaryKey": true, 1277 + "notNull": true 1278 + }, 1279 + "state": { 1280 + "name": "state", 1281 + "type": "text", 1282 + "primaryKey": false, 1283 + "notNull": true 1284 + }, 1285 + "created_at": { 1286 + "name": "created_at", 1287 + "type": "timestamp", 1288 + "primaryKey": false, 1289 + "notNull": false, 1290 + "default": "now()" 1291 + } 1292 + }, 1293 + "indexes": {}, 1294 + "foreignKeys": {}, 1295 + "compositePrimaryKeys": {}, 1296 + "uniqueConstraints": {}, 1297 + "policies": {}, 1298 + "checkConstraints": {}, 1299 + "isRLSEnabled": false 1300 + }, 1301 + "public.auth_refresh_tokens": { 1302 + "name": "auth_refresh_tokens", 1303 + "schema": "", 1304 + "columns": { 1305 + "token_id": { 1306 + "name": "token_id", 1307 + "type": "text", 1308 + "primaryKey": true, 1309 + "notNull": true 1310 + }, 1311 + "user_did": { 1312 + "name": "user_did", 1313 + "type": "text", 1314 + "primaryKey": false, 1315 + "notNull": true 1316 + }, 1317 + "refresh_token": { 1318 + "name": "refresh_token", 1319 + "type": "text", 1320 + "primaryKey": false, 1321 + "notNull": true 1322 + }, 1323 + "issued_at": { 1324 + "name": "issued_at", 1325 + "type": "timestamp", 1326 + "primaryKey": false, 1327 + "notNull": true 1328 + }, 1329 + "expires_at": { 1330 + "name": "expires_at", 1331 + "type": "timestamp", 1332 + "primaryKey": false, 1333 + "notNull": true 1334 + }, 1335 + "revoked": { 1336 + "name": "revoked", 1337 + "type": "boolean", 1338 + "primaryKey": false, 1339 + "notNull": false, 1340 + "default": false 1341 + } 1342 + }, 1343 + "indexes": {}, 1344 + "foreignKeys": { 1345 + "auth_refresh_tokens_user_did_users_id_fk": { 1346 + "name": "auth_refresh_tokens_user_did_users_id_fk", 1347 + "tableFrom": "auth_refresh_tokens", 1348 + "tableTo": "users", 1349 + "columnsFrom": ["user_did"], 1350 + "columnsTo": ["id"], 1351 + "onDelete": "no action", 1352 + "onUpdate": "no action" 1353 + } 1354 + }, 1355 + "compositePrimaryKeys": {}, 1356 + "uniqueConstraints": {}, 1357 + "policies": {}, 1358 + "checkConstraints": {}, 1359 + "isRLSEnabled": false 1360 + }, 1361 + "public.users": { 1362 + "name": "users", 1363 + "schema": "", 1364 + "columns": { 1365 + "id": { 1366 + "name": "id", 1367 + "type": "text", 1368 + "primaryKey": true, 1369 + "notNull": true 1370 + }, 1371 + "handle": { 1372 + "name": "handle", 1373 + "type": "text", 1374 + "primaryKey": false, 1375 + "notNull": false 1376 + }, 1377 + "linked_at": { 1378 + "name": "linked_at", 1379 + "type": "timestamp", 1380 + "primaryKey": false, 1381 + "notNull": true 1382 + }, 1383 + "last_login_at": { 1384 + "name": "last_login_at", 1385 + "type": "timestamp", 1386 + "primaryKey": false, 1387 + "notNull": true 1388 + } 1389 + }, 1390 + "indexes": {}, 1391 + "foreignKeys": {}, 1392 + "compositePrimaryKeys": {}, 1393 + "uniqueConstraints": {}, 1394 + "policies": {}, 1395 + "checkConstraints": {}, 1396 + "isRLSEnabled": false 1397 + } 1398 + }, 1399 + "enums": {}, 1400 + "schemas": {}, 1401 + "sequences": {}, 1402 + "roles": {}, 1403 + "policies": {}, 1404 + "views": {}, 1405 + "_meta": { 1406 + "columns": {}, 1407 + "schemas": {}, 1408 + "tables": {} 1409 + } 1410 + }
+7
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 106 106 "when": 1769804929810, 107 107 "tag": "0014_cuddly_nighthawk", 108 108 "breakpoints": true 109 + }, 110 + { 111 + "idx": 15, 112 + "version": "7", 113 + "when": 1770074100791, 114 + "tag": "0015_small_loki", 115 + "breakpoints": true 109 116 } 110 117 ] 111 118 }
+2
src/shared/infrastructure/events/BullMQEventPublisher.ts
··· 63 63 return [QueueNames.FEEDS, QueueNames.NOTIFICATIONS]; 64 64 case EventNames.CARD_REMOVED_FROM_LIBRARY: 65 65 return [QueueNames.NOTIFICATIONS]; 66 + case EventNames.CARD_REMOVED_FROM_COLLECTION: 67 + return [QueueNames.NOTIFICATIONS]; 66 68 default: 67 69 return [QueueNames.FEEDS]; 68 70 }
+1
src/shared/infrastructure/events/EventConfig.ts
··· 3 3 CARD_ADDED_TO_COLLECTION: 'CardAddedToCollectionEvent', 4 4 COLLECTION_CREATED: 'CollectionCreatedEvent', 5 5 CARD_REMOVED_FROM_LIBRARY: 'CardRemovedFromLibraryEvent', 6 + CARD_REMOVED_FROM_COLLECTION: 'CardRemovedFromCollectionEvent', 6 7 } as const; 7 8 8 9 export type EventName = (typeof EventNames)[keyof typeof EventNames];
+36
src/shared/infrastructure/events/EventMapper.ts
··· 2 2 import { CardAddedToLibraryEvent } from '../../../modules/cards/domain/events/CardAddedToLibraryEvent'; 3 3 import { CardAddedToCollectionEvent } from '../../../modules/cards/domain/events/CardAddedToCollectionEvent'; 4 4 import { CardRemovedFromLibraryEvent } from '../../../modules/cards/domain/events/CardRemovedFromLibraryEvent'; 5 + import { CardRemovedFromCollectionEvent } from '../../../modules/cards/domain/events/CardRemovedFromCollectionEvent'; 5 6 import { CollectionCreatedEvent } from '../../../modules/cards/domain/events/CollectionCreatedEvent'; 6 7 import { CardId } from '../../../modules/cards/domain/value-objects/CardId'; 7 8 import { CollectionId } from '../../../modules/cards/domain/value-objects/CollectionId'; ··· 33 34 eventType: typeof EventNames.CARD_REMOVED_FROM_LIBRARY; 34 35 cardId: string; 35 36 curatorId: string; 37 + } 38 + 39 + export interface SerializedCardRemovedFromCollectionEvent 40 + extends SerializedEvent { 41 + eventType: typeof EventNames.CARD_REMOVED_FROM_COLLECTION; 42 + cardId: string; 43 + collectionId: string; 44 + removedBy: string; 36 45 } 37 46 38 47 export interface SerializedCollectionCreatedEvent extends SerializedEvent { ··· 46 55 | SerializedCardAddedToLibraryEvent 47 56 | SerializedCardAddedToCollectionEvent 48 57 | SerializedCardRemovedFromLibraryEvent 58 + | SerializedCardRemovedFromCollectionEvent 49 59 | SerializedCollectionCreatedEvent; 50 60 51 61 export class EventMapper { ··· 83 93 }; 84 94 } 85 95 96 + if (event instanceof CardRemovedFromCollectionEvent) { 97 + return { 98 + eventType: EventNames.CARD_REMOVED_FROM_COLLECTION, 99 + aggregateId: event.getAggregateId().toString(), 100 + dateTimeOccurred: event.dateTimeOccurred.toISOString(), 101 + cardId: event.cardId.getValue().toString(), 102 + collectionId: event.collectionId.getValue().toString(), 103 + removedBy: event.removedBy.value, 104 + }; 105 + } 106 + 86 107 if (event instanceof CollectionCreatedEvent) { 87 108 return { 88 109 eventType: EventNames.COLLECTION_CREATED, ··· 139 160 return CardRemovedFromLibraryEvent.reconstruct( 140 161 cardId, 141 162 curatorId, 163 + dateTimeOccurred, 164 + ).unwrap(); 165 + } 166 + case EventNames.CARD_REMOVED_FROM_COLLECTION: { 167 + const cardId = CardId.createFromString(eventData.cardId).unwrap(); 168 + const collectionId = CollectionId.createFromString( 169 + eventData.collectionId, 170 + ).unwrap(); 171 + const removedBy = CuratorId.create(eventData.removedBy).unwrap(); 172 + const dateTimeOccurred = new Date(eventData.dateTimeOccurred); 173 + 174 + return CardRemovedFromCollectionEvent.reconstruct( 175 + cardId, 176 + collectionId, 177 + removedBy, 142 178 dateTimeOccurred, 143 179 ).unwrap(); 144 180 }
+1
src/shared/infrastructure/http/app.ts
··· 115 115 controllers.getCollectionsController, 116 116 controllers.getCollectionsForUrlController, 117 117 controllers.searchCollectionsController, 118 + controllers.getOpenCollectionsWithContributorController, 118 119 ); 119 120 120 121 const feedRouter = createFeedRoutes(
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 33 33 import { GenerateExtensionTokensController } from 'src/modules/user/infrastructure/http/controllers/GenerateExtensionTokensController'; 34 34 import { GetUserCollectionsController } from 'src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController'; 35 35 import { SearchCollectionsController } from 'src/modules/cards/infrastructure/http/controllers/SearchCollectionsController'; 36 + import { GetOpenCollectionsWithContributorController } from 'src/modules/cards/infrastructure/http/controllers/GetOpenCollectionsWithContributorController'; 36 37 import { GetCollectionPageByAtUriController } from 'src/modules/cards/infrastructure/http/controllers/GetCollectionPageByAtUriController'; 37 38 import { GetUrlStatusForMyLibraryController } from '../../../../modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController'; 38 39 import { GetLibrariesForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetLibrariesForUrlController'; ··· 75 76 getMyCollectionsController: GetMyCollectionsController; 76 77 getCollectionsController: GetUserCollectionsController; 77 78 searchCollectionsController: SearchCollectionsController; 79 + getOpenCollectionsWithContributorController: GetOpenCollectionsWithContributorController; 78 80 getUrlStatusForMyLibraryController: GetUrlStatusForMyLibraryController; 79 81 getLibrariesForUrlController: GetLibrariesForUrlController; 80 82 getCollectionsForUrlController: GetCollectionsForUrlController; ··· 192 194 searchCollectionsController: new SearchCollectionsController( 193 195 useCases.searchCollectionsUseCase, 194 196 ), 197 + getOpenCollectionsWithContributorController: 198 + new GetOpenCollectionsWithContributorController( 199 + useCases.getOpenCollectionsWithContributorUseCase, 200 + ), 195 201 getUrlStatusForMyLibraryController: 196 202 new GetUrlStatusForMyLibraryController( 197 203 useCases.getUrlStatusForMyLibraryUseCase,
+3 -10
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 155 155 sharedServices.atProtoAgentService, 156 156 collections.collection, 157 157 collections.collectionLink, 158 - collections.marginCollection, 159 - collections.marginCollectionItem, 158 + collections.collectionLinkRemoval, 160 159 ); 161 160 162 161 const cardPublisher = useFakePublishers ··· 164 163 : new ATProtoCardPublisher( 165 164 sharedServices.atProtoAgentService, 166 165 collections.card, 167 - collections.marginBookmark, 168 166 ); 169 167 170 168 const authMiddleware = new AuthMiddleware( ··· 401 399 atProtoAgentService, 402 400 collections.collection, 403 401 collections.collectionLink, 404 - collections.marginCollection, 405 - collections.marginCollectionItem, 402 + collections.collectionLinkRemoval, 406 403 ); 407 404 408 405 const cardPublisher = useFakePublishers 409 406 ? new FakeCardPublisher() 410 - : new ATProtoCardPublisher( 411 - atProtoAgentService, 412 - collections.card, 413 - collections.marginBookmark, 414 - ); 407 + : new ATProtoCardPublisher(atProtoAgentService, collections.card); 415 408 416 409 // Create domain services 417 410 const cardCollectionService = new CardCollectionService(
+18
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 27 27 import { AddActivityToFeedUseCase } from '../../../../modules/feeds/application/useCases/commands/AddActivityToFeedUseCase'; 28 28 import { GetCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionsUseCase'; 29 29 import { SearchCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/SearchCollectionsUseCase'; 30 + import { GetOpenCollectionsWithContributorUseCase } from 'src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase'; 30 31 import { GetCollectionPageByAtUriUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase'; 31 32 import { GetUrlStatusForMyLibraryUseCase } from '../../../../modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 32 33 import { GetLibrariesForUrlUseCase } from '../../../../modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase'; ··· 44 45 import { ProcessMarginBookmarkFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessMarginBookmarkFirehoseEventUseCase'; 45 46 import { ProcessMarginCollectionFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessMarginCollectionFirehoseEventUseCase'; 46 47 import { ProcessMarginCollectionItemFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessMarginCollectionItemFirehoseEventUseCase'; 48 + import { ProcessCollectionLinkRemovalFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessCollectionLinkRemovalFirehoseEventUseCase'; 47 49 import { GetMyNotificationsUseCase } from '../../../../modules/notifications/application/useCases/queries/GetMyNotificationsUseCase'; 48 50 import { GetUnreadNotificationCountUseCase } from '../../../../modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase'; 49 51 import { MarkNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase'; ··· 69 71 processMarginBookmarkFirehoseEventUseCase: ProcessMarginBookmarkFirehoseEventUseCase; 70 72 processMarginCollectionFirehoseEventUseCase: ProcessMarginCollectionFirehoseEventUseCase; 71 73 processMarginCollectionItemFirehoseEventUseCase: ProcessMarginCollectionItemFirehoseEventUseCase; 74 + processCollectionLinkRemovalFirehoseEventUseCase: ProcessCollectionLinkRemovalFirehoseEventUseCase; 72 75 } 73 76 74 77 export interface UseCases { ··· 99 102 getCollectionPageByAtUriUseCase: GetCollectionPageByAtUriUseCase; 100 103 getCollectionsUseCase: GetCollectionsUseCase; 101 104 searchCollectionsUseCase: SearchCollectionsUseCase; 105 + getOpenCollectionsWithContributorUseCase: GetOpenCollectionsWithContributorUseCase; 102 106 getUrlStatusForMyLibraryUseCase: GetUrlStatusForMyLibraryUseCase; 103 107 getLibrariesForUrlUseCase: GetLibrariesForUrlUseCase; 104 108 getCollectionsForUrlUseCase: GetCollectionsForUrlUseCase; ··· 200 204 removeCardFromCollectionUseCase: new RemoveCardFromCollectionUseCase( 201 205 repositories.cardRepository, 202 206 services.cardCollectionService, 207 + services.eventPublisher, 203 208 ), 204 209 getUrlMetadataUseCase: new GetUrlMetadataUseCase( 205 210 services.metadataService, ··· 249 254 services.profileService, 250 255 services.identityResolutionService, 251 256 ), 257 + getOpenCollectionsWithContributorUseCase: 258 + new GetOpenCollectionsWithContributorUseCase( 259 + repositories.collectionQueryRepository, 260 + services.profileService, 261 + services.identityResolutionService, 262 + ), 252 263 getUrlStatusForMyLibraryUseCase: new GetUrlStatusForMyLibraryUseCase( 253 264 repositories.cardRepository, 254 265 repositories.cardQueryRepository, ··· 415 426 updateUrlCardAssociationsUseCase, 416 427 ); 417 428 429 + const processCollectionLinkRemovalFirehoseEventUseCase = 430 + new ProcessCollectionLinkRemovalFirehoseEventUseCase( 431 + repositories.atUriResolutionService, 432 + updateUrlCardAssociationsUseCase, 433 + ); 434 + 418 435 const processMarginBookmarkFirehoseEventUseCase = 419 436 new ProcessMarginBookmarkFirehoseEventUseCase( 420 437 repositories.atUriResolutionService, ··· 468 485 processCardFirehoseEventUseCase, 469 486 processCollectionFirehoseEventUseCase, 470 487 processCollectionLinkFirehoseEventUseCase, 488 + processCollectionLinkRemovalFirehoseEventUseCase, 471 489 processMarginBookmarkFirehoseEventUseCase, 472 490 processMarginCollectionFirehoseEventUseCase, 473 491 processMarginCollectionItemFirehoseEventUseCase,
+26
src/shared/infrastructure/processes/InMemoryEventWorkerProcess.ts
··· 9 9 import { CardAddedToLibraryEventHandler as NotificationCardAddedToLibraryEventHandler } from '../../../modules/notifications/application/eventHandlers/CardAddedToLibraryEventHandler'; 10 10 import { CardAddedToCollectionEventHandler } from '../../../modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler'; 11 11 import { CardAddedToCollectionEventHandler as NotificationCardAddedToCollectionEventHandler } from '../../../modules/notifications/application/eventHandlers/CardAddedToCollectionEventHandler'; 12 + import { CollectionContributionEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionEventHandler'; 13 + import { CollectionContributionCleanupEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionCleanupEventHandler'; 12 14 import { CardCollectionSaga } from '../../../modules/feeds/application/sagas/CardCollectionSaga'; 13 15 import { CardNotificationSaga } from '../../../modules/notifications/application/sagas/CardNotificationSaga'; 14 16 import { EventNames } from '../events/EventConfig'; ··· 77 79 const notificationCardAddedToCollectionHandler = 78 80 new NotificationCardAddedToCollectionEventHandler(cardNotificationSaga); 79 81 82 + // Collection contribution notification handlers (direct, no saga) 83 + const collectionContributionHandler = 84 + new CollectionContributionEventHandler( 85 + useCases.createNotificationUseCase, 86 + repositories.collectionRepository, 87 + ); 88 + const collectionContributionCleanupHandler = 89 + new CollectionContributionCleanupEventHandler( 90 + repositories.notificationRepository, 91 + repositories.collectionRepository, 92 + ); 93 + 80 94 // Register feed handlers 81 95 await subscriber.subscribe( 82 96 EventNames.CARD_ADDED_TO_LIBRARY, ··· 103 117 await subscriber.subscribe( 104 118 EventNames.CARD_ADDED_TO_COLLECTION, 105 119 notificationCardAddedToCollectionHandler, 120 + ); 121 + 122 + // Collection contribution handler also subscribes to CARD_ADDED_TO_COLLECTION 123 + // Both handlers will process the event independently 124 + await subscriber.subscribe( 125 + EventNames.CARD_ADDED_TO_COLLECTION, 126 + collectionContributionHandler, 127 + ); 128 + 129 + await subscriber.subscribe( 130 + EventNames.CARD_REMOVED_FROM_COLLECTION, 131 + collectionContributionCleanupHandler, 106 132 ); 107 133 } 108 134 }
+26
src/shared/infrastructure/processes/NotificationWorkerProcess.ts
··· 7 7 import { CardAddedToLibraryEventHandler } from '../../../modules/notifications/application/eventHandlers/CardAddedToLibraryEventHandler'; 8 8 import { CardAddedToCollectionEventHandler } from '../../../modules/notifications/application/eventHandlers/CardAddedToCollectionEventHandler'; 9 9 import { CardRemovedFromLibraryEventHandler } from '../../../modules/notifications/application/eventHandlers/CardRemovedFromLibraryEventHandler'; 10 + import { CollectionContributionEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionEventHandler'; 11 + import { CollectionContributionCleanupEventHandler } from '../../../modules/notifications/application/eventHandlers/CollectionContributionCleanupEventHandler'; 10 12 import { CardNotificationSaga } from '../../../modules/notifications/application/sagas/CardNotificationSaga'; 11 13 import { QueueNames } from '../events/QueueConfig'; 12 14 import { EventNames } from '../events/EventConfig'; ··· 56 58 const cardRemovedFromLibraryHandler = 57 59 new CardRemovedFromLibraryEventHandler(cardNotificationSaga); 58 60 61 + // Collection contribution notification handlers (direct, no saga) 62 + const collectionContributionHandler = 63 + new CollectionContributionEventHandler( 64 + useCases.createNotificationUseCase, 65 + repositories.collectionRepository, 66 + ); 67 + const collectionContributionCleanupHandler = 68 + new CollectionContributionCleanupEventHandler( 69 + repositories.notificationRepository, 70 + repositories.collectionRepository, 71 + ); 72 + 59 73 await subscriber.subscribe( 60 74 EventNames.CARD_ADDED_TO_LIBRARY, 61 75 cardAddedToLibraryHandler, ··· 66 80 cardAddedToCollectionHandler, 67 81 ); 68 82 83 + // Collection contribution handler also subscribes to CARD_ADDED_TO_COLLECTION 84 + // Both handlers will process the event independently 85 + await subscriber.subscribe( 86 + EventNames.CARD_ADDED_TO_COLLECTION, 87 + collectionContributionHandler, 88 + ); 89 + 69 90 await subscriber.subscribe( 70 91 EventNames.CARD_REMOVED_FROM_LIBRARY, 71 92 cardRemovedFromLibraryHandler, 93 + ); 94 + 95 + await subscriber.subscribe( 96 + EventNames.CARD_REMOVED_FROM_COLLECTION, 97 + collectionContributionCleanupHandler, 72 98 ); 73 99 } 74 100 }
+14 -1
src/types/src/api/requests.ts
··· 32 32 SEMBLE = 'semble', 33 33 } 34 34 35 + // Collection Access Type enum 36 + export enum CollectionAccessType { 37 + OPEN = 'OPEN', 38 + CLOSED = 'CLOSED', 39 + } 40 + 35 41 // Command request types 36 42 export interface AddUrlToLibraryRequest { 37 43 url: string; ··· 75 81 export interface CreateCollectionRequest { 76 82 name: string; 77 83 description?: string; 84 + accessType?: CollectionAccessType; 78 85 } 79 86 80 87 export interface UpdateCollectionRequest { 81 88 collectionId: string; 82 89 name: string; 83 90 description?: string; 91 + accessType?: CollectionAccessType; 84 92 } 85 93 86 94 export interface DeleteCollectionRequest { ··· 217 225 export interface SearchCollectionsParams extends PaginatedSortedParams { 218 226 searchText?: string; 219 227 identifier?: string; // Can be DID or handle 220 - accessType?: 'OPEN' | 'CLOSED'; 228 + accessType?: CollectionAccessType; 229 + } 230 + 231 + export interface GetOpenCollectionsWithContributorParams 232 + extends PaginatedSortedParams { 233 + identifier: string; // Can be DID or handle 221 234 } 222 235 223 236 // Notification request types
+4
src/types/src/api/responses.ts
··· 5 5 CollectionSorting, 6 6 FeedPagination, 7 7 } from './common'; 8 + import { CollectionAccessType } from './requests'; 8 9 9 10 // Command response types 10 11 export interface AddUrlToLibraryResponse { ··· 96 97 name: string; 97 98 author: User; 98 99 description?: string; 100 + accessType?: CollectionAccessType; 99 101 cardCount: number; 100 102 createdAt: string; 101 103 updatedAt: string; ··· 137 139 uri?: string; 138 140 name: string; 139 141 description?: string; 142 + accessType?: CollectionAccessType; 140 143 author: User; 141 144 urlCards: UrlCard[]; 142 145 cardCount: number; ··· 331 334 USER_ADDED_YOUR_CARD = 'USER_ADDED_YOUR_CARD', 332 335 USER_ADDED_YOUR_BSKY_POST = 'USER_ADDED_YOUR_BSKY_POST', 333 336 USER_ADDED_YOUR_COLLECTION = 'USER_ADDED_YOUR_COLLECTION', 337 + USER_ADDED_TO_YOUR_COLLECTION = 'USER_ADDED_TO_YOUR_COLLECTION', 334 338 } 335 339 336 340 export interface NotificationItem {
+7
src/webapp/api-client/ApiClient.ts
··· 81 81 MarkAllNotificationsAsReadResponse, 82 82 GetGemActivityFeedParams, 83 83 SearchCollectionsParams, 84 + GetOpenCollectionsWithContributorParams, 84 85 } from '@semble/types'; 85 86 86 87 // Main API Client class using composition ··· 160 161 params: GetCollectionsParams, 161 162 ): Promise<GetCollectionsResponse> { 162 163 return this.queryClient.getUserCollections(params); 164 + } 165 + 166 + async getOpenCollectionsWithContributor( 167 + params: GetOpenCollectionsWithContributorParams, 168 + ): Promise<GetCollectionsResponse> { 169 + return this.queryClient.getOpenCollectionsWithContributor(params); 163 170 } 164 171 165 172 async getUrlStatusForMyLibrary(
+18
src/webapp/api-client/clients/QueryClient.ts
··· 32 32 SearchAtProtoAccountsResponse, 33 33 SearchLeafletDocsForUrlParams, 34 34 SearchLeafletDocsForUrlResponse, 35 + GetOpenCollectionsWithContributorParams, 35 36 } from '@semble/types'; 36 37 37 38 export class QueryClient extends BaseClient { ··· 322 323 'GET', 323 324 `/api/search/leaflet-docs?${searchParams}`, 324 325 ); 326 + } 327 + 328 + async getOpenCollectionsWithContributor( 329 + params: GetOpenCollectionsWithContributorParams, 330 + ): Promise<GetCollectionsResponse> { 331 + const searchParams = new URLSearchParams(); 332 + if (params.page) searchParams.set('page', params.page.toString()); 333 + if (params.limit) searchParams.set('limit', params.limit.toString()); 334 + if (params.sortBy) searchParams.set('sortBy', params.sortBy); 335 + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); 336 + 337 + const queryString = searchParams.toString(); 338 + const endpoint = queryString 339 + ? `/api/collections/contributed/${params.identifier}?${queryString}` 340 + : `/api/collections/contributed/${params.identifier}`; 341 + 342 + return this.request<GetCollectionsResponse>('GET', endpoint); 325 343 } 326 344 }
+1
src/workers/firehose-worker.ts
··· 38 38 useCases.processMarginBookmarkFirehoseEventUseCase, 39 39 useCases.processMarginCollectionFirehoseEventUseCase, 40 40 useCases.processMarginCollectionItemFirehoseEventUseCase, 41 + useCases.processCollectionLinkRemovalFirehoseEventUseCase, 41 42 ); 42 43 43 44 const firehoseEventHandler = new FirehoseEventHandler(