This repository has no description
0

Configure Feed

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

semble / src / modules / cards / domain / services / CardCollectionService.ts
11 kB 333 lines
1import { Result, ok, err } from '../../../../shared/core/Result'; 2import { Card } from '../Card'; 3import { Collection } from '../Collection'; 4import { CuratorId } from '../value-objects/CuratorId'; 5import { CollectionId } from '../value-objects/CollectionId'; 6import { CardId } from '../value-objects/CardId'; 7import { ICollectionRepository } from '../ICollectionRepository'; 8import { ICollectionPublisher } from '../../application/ports/ICollectionPublisher'; 9import { ICardRepository } from '../ICardRepository'; 10import { AppError } from '../../../../shared/core/AppError'; 11import { DomainService } from '../../../../shared/domain/DomainService'; 12import { 13 PublishedRecordId, 14 PublishedRecordIdProps, 15} from '../value-objects/PublishedRecordId'; 16import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; 17 18export interface CardCollectionServiceOptions { 19 skipPublishing?: boolean; 20 publishedRecordIds?: Map<string, PublishedRecordId>; // collectionId -> publishedRecordId 21 timestamp?: Date; 22} 23 24export class CardCollectionValidationError extends Error { 25 constructor(message: string) { 26 super(message); 27 this.name = 'CardCollectionValidationError'; 28 } 29} 30 31export class CardCollectionService implements DomainService { 32 constructor( 33 private collectionRepository: ICollectionRepository, 34 private collectionPublisher: ICollectionPublisher, 35 private cardRepository: ICardRepository, 36 ) {} 37 38 async addCardToCollection( 39 card: Card, 40 collectionId: CollectionId, 41 curatorId: CuratorId, 42 viaCardId?: CardId, 43 options?: CardCollectionServiceOptions, 44 ): Promise< 45 Result< 46 Collection, 47 | CardCollectionValidationError 48 | AuthenticationError 49 | AppError.UnexpectedError 50 > 51 > { 52 try { 53 // Find the collection 54 const collectionResult = 55 await this.collectionRepository.findById(collectionId); 56 if (collectionResult.isErr()) { 57 return err(AppError.UnexpectedError.create(collectionResult.error)); 58 } 59 60 const collection = collectionResult.value; 61 if (!collection) { 62 return err( 63 new CardCollectionValidationError( 64 `Collection not found: ${collectionId.getStringValue()}`, 65 ), 66 ); 67 } 68 69 // Add card to collection 70 const addCardResult = collection.addCard( 71 card.cardId, 72 curatorId, 73 viaCardId, 74 options?.timestamp, 75 ); 76 if (addCardResult.isErr()) { 77 return err( 78 new CardCollectionValidationError( 79 `Failed to add card to collection: ${addCardResult.error.message}`, 80 ), 81 ); 82 } 83 84 // Handle publishing based on options 85 if (options?.skipPublishing && options?.publishedRecordIds) { 86 const publishedRecordId = options.publishedRecordIds.get( 87 collectionId.getStringValue(), 88 ); 89 if (publishedRecordId) { 90 // Skip publishing and use provided record ID 91 collection.markCardLinkAsPublished(card.cardId, publishedRecordId); 92 } 93 } else { 94 // Resolve via card published record ID if needed 95 let viaCardPublishedRecordId: PublishedRecordIdProps | undefined; 96 if (viaCardId) { 97 const viaCardResult = await this.cardRepository.findById(viaCardId); 98 if (viaCardResult.isOk() && viaCardResult.value?.publishedRecordId) { 99 viaCardPublishedRecordId = 100 viaCardResult.value.publishedRecordId.getValue(); 101 } 102 } 103 104 // Publish the collection link normally 105 const publishLinkResult = 106 await this.collectionPublisher.publishCardAddedToCollection( 107 card, 108 collection, 109 curatorId, 110 viaCardPublishedRecordId, 111 ); 112 if (publishLinkResult.isErr()) { 113 // Propagate authentication errors 114 if (publishLinkResult.error instanceof AuthenticationError) { 115 return err(publishLinkResult.error); 116 } 117 return err( 118 new CardCollectionValidationError( 119 `Failed to publish collection link: ${publishLinkResult.error.message}`, 120 ), 121 ); 122 } 123 124 // Mark the card link as published in the collection 125 collection.markCardLinkAsPublished( 126 card.cardId, 127 publishLinkResult.value, 128 ); 129 } 130 131 // Save the updated collection 132 const saveCollectionResult = 133 await this.collectionRepository.save(collection); 134 if (saveCollectionResult.isErr()) { 135 return err(AppError.UnexpectedError.create(saveCollectionResult.error)); 136 } 137 138 return ok(collection); 139 } catch (error) { 140 return err(AppError.UnexpectedError.create(error)); 141 } 142 } 143 144 async addCardToCollections( 145 card: Card, 146 collectionIds: CollectionId[], 147 curatorId: CuratorId, 148 viaCardId?: CardId, 149 options?: CardCollectionServiceOptions, 150 ): Promise< 151 Result< 152 Collection[], 153 | CardCollectionValidationError 154 | AuthenticationError 155 | AppError.UnexpectedError 156 > 157 > { 158 const updatedCollections: Collection[] = []; 159 160 for (const collectionId of collectionIds) { 161 const result = await this.addCardToCollection( 162 card, 163 collectionId, 164 curatorId, 165 viaCardId, 166 options, 167 ); 168 if (result.isErr()) { 169 return err(result.error); 170 } 171 updatedCollections.push(result.value); 172 } 173 return ok(updatedCollections); 174 } 175 176 async removeCardFromCollection( 177 card: Card, 178 collectionId: CollectionId, 179 curatorId: CuratorId, 180 options?: CardCollectionServiceOptions, 181 ): Promise< 182 Result< 183 Collection | null, 184 | CardCollectionValidationError 185 | AuthenticationError 186 | AppError.UnexpectedError 187 > 188 > { 189 try { 190 // Find the collection 191 const collectionResult = 192 await this.collectionRepository.findById(collectionId); 193 if (collectionResult.isErr()) { 194 return err(AppError.UnexpectedError.create(collectionResult.error)); 195 } 196 197 const collection = collectionResult.value; 198 if (!collection) { 199 return err( 200 new CardCollectionValidationError( 201 `Collection not found: ${collectionId.getStringValue()}`, 202 ), 203 ); 204 } 205 206 // Check if card is in collection 207 const cardLink = collection.cardLinks.find((link) => 208 link.cardId.equals(card.cardId), 209 ); 210 if (!cardLink) { 211 // Card is not in collection, nothing to do 212 return ok(null); 213 } 214 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 225 if (!options?.skipPublishing && cardLink.publishedRecordId) { 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 ); 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 271 return err( 272 new CardCollectionValidationError( 273 'User does not have permission to remove this card from the collection', 274 ), 275 ); 276 } 277 } 278 279 // Remove card from collection 280 const removeCardResult = collection.removeCard(card.cardId, curatorId); 281 if (removeCardResult.isErr()) { 282 return err( 283 new CardCollectionValidationError( 284 `Failed to remove card from collection: ${removeCardResult.error.message}`, 285 ), 286 ); 287 } 288 289 // Save the updated collection 290 const saveCollectionResult = 291 await this.collectionRepository.save(collection); 292 if (saveCollectionResult.isErr()) { 293 return err(AppError.UnexpectedError.create(saveCollectionResult.error)); 294 } 295 296 return ok(collection); 297 } catch (error) { 298 return err(AppError.UnexpectedError.create(error)); 299 } 300 } 301 302 async removeCardFromCollections( 303 card: Card, 304 collectionIds: CollectionId[], 305 curatorId: CuratorId, 306 options?: CardCollectionServiceOptions, 307 ): Promise< 308 Result< 309 Collection[], 310 | CardCollectionValidationError 311 | AuthenticationError 312 | AppError.UnexpectedError 313 > 314 > { 315 const updatedCollections: Collection[] = []; 316 317 for (const collectionId of collectionIds) { 318 const result = await this.removeCardFromCollection( 319 card, 320 collectionId, 321 curatorId, 322 options, 323 ); 324 if (result.isErr()) { 325 return err(result.error); 326 } 327 if (result.value !== null) { 328 updatedCollections.push(result.value); 329 } 330 } 331 return ok(updatedCollections); 332 } 333}