A social knowledge tool for researchers built on ATProto
at development 377 lines 10 kB view raw
1import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; 2import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID'; 3import { ok, err, Result } from '../../../shared/core/Result'; 4import { CollectionId } from './value-objects/CollectionId'; 5import { CardId } from './value-objects/CardId'; 6import { CuratorId } from './value-objects/CuratorId'; 7import { CollectionName } from './value-objects/CollectionName'; 8import { CollectionDescription } from './value-objects/CollectionDescription'; 9import { PublishedRecordId } from './value-objects/PublishedRecordId'; 10import { CardAddedToCollectionEvent } from './events/CardAddedToCollectionEvent'; 11import { CollectionCreatedEvent } from './events/CollectionCreatedEvent'; 12 13export interface CardLink { 14 cardId: CardId; 15 addedBy: CuratorId; 16 addedAt: Date; 17 publishedRecordId?: PublishedRecordId; // AT URI of the link record 18} 19 20export enum CollectionAccessType { 21 OPEN = 'OPEN', 22 CLOSED = 'CLOSED', 23} 24 25export class CollectionAccessError extends Error { 26 constructor(message: string) { 27 super(message); 28 this.name = 'CollectionAccessError'; 29 } 30} 31 32export class CollectionValidationError extends Error { 33 constructor(message: string) { 34 super(message); 35 this.name = 'CollectionValidationError'; 36 } 37} 38 39interface CollectionProps { 40 authorId: CuratorId; 41 name: CollectionName; 42 description?: CollectionDescription; 43 accessType: CollectionAccessType; 44 collaboratorIds: CuratorId[]; 45 cardLinks: CardLink[]; // Instead of cardIds: CardId[] 46 cardCount: number; 47 publishedRecordId?: PublishedRecordId; // Collection's own published record 48 createdAt: Date; 49 updatedAt: Date; 50} 51 52export class Collection extends AggregateRoot<CollectionProps> { 53 get collectionId(): CollectionId { 54 return CollectionId.create(this._id).unwrap(); 55 } 56 57 get authorId(): CuratorId { 58 return this.props.authorId; 59 } 60 61 get name(): CollectionName { 62 return this.props.name; 63 } 64 65 get description(): CollectionDescription | undefined { 66 return this.props.description; 67 } 68 69 get accessType(): CollectionAccessType { 70 return this.props.accessType; 71 } 72 73 get collaboratorIds(): CuratorId[] { 74 return [...this.props.collaboratorIds]; 75 } 76 77 get cardIds(): CardId[] { 78 return this.props.cardLinks.map((link) => link.cardId); 79 } 80 81 get cardLinks(): CardLink[] { 82 return [...this.props.cardLinks]; 83 } 84 85 get cardCount(): number { 86 return this.props.cardCount; 87 } 88 89 get unpublishedCardLinks(): CardLink[] { 90 return this.props.cardLinks.filter((link) => !link.publishedRecordId); 91 } 92 93 get hasUnpublishedLinks(): boolean { 94 return this.unpublishedCardLinks.length > 0; 95 } 96 97 get createdAt(): Date { 98 return this.props.createdAt; 99 } 100 101 get updatedAt(): Date { 102 return this.props.updatedAt; 103 } 104 105 get isOpen(): boolean { 106 return this.props.accessType === CollectionAccessType.OPEN; 107 } 108 109 get isClosed(): boolean { 110 return this.props.accessType === CollectionAccessType.CLOSED; 111 } 112 113 get publishedRecordId(): PublishedRecordId | undefined { 114 return this.props.publishedRecordId; 115 } 116 117 get isPublished(): boolean { 118 return this.props.publishedRecordId !== undefined; 119 } 120 121 private constructor(props: CollectionProps, id?: UniqueEntityID) { 122 super(props, id); 123 } 124 125 public static create( 126 props: Omit< 127 CollectionProps, 128 'name' | 'description' | 'cardLinks' | 'cardCount' 129 > & { 130 name: string; 131 description?: string; 132 cardLinks?: CardLink[]; 133 cardCount?: number; 134 }, 135 id?: UniqueEntityID, 136 ): Result<Collection, CollectionValidationError> { 137 // Validate and create CollectionName 138 const nameResult = CollectionName.create(props.name); 139 if (nameResult.isErr()) { 140 return err(new CollectionValidationError(nameResult.error.message)); 141 } 142 143 // Validate and create CollectionDescription if provided 144 let description: CollectionDescription | undefined; 145 if (props.description) { 146 const descriptionResult = CollectionDescription.create(props.description); 147 if (descriptionResult.isErr()) { 148 return err( 149 new CollectionValidationError(descriptionResult.error.message), 150 ); 151 } 152 description = descriptionResult.value; 153 } 154 155 // Validate access type 156 if (!Object.values(CollectionAccessType).includes(props.accessType)) { 157 return err(new CollectionValidationError('Invalid access type')); 158 } 159 160 const collectionProps: CollectionProps = { 161 ...props, 162 name: nameResult.value, 163 description, 164 cardLinks: props.cardLinks || [], 165 cardCount: props.cardCount ?? (props.cardLinks || []).length, 166 }; 167 168 const collection = new Collection(collectionProps, id); 169 170 // Raise domain event for new collections (when no id is provided) 171 if (!id) { 172 collection.addDomainEvent( 173 CollectionCreatedEvent.create( 174 collection.collectionId, 175 collection.authorId, 176 collection.name.value, 177 ).unwrap(), 178 ); 179 } 180 181 return ok(collection); 182 } 183 184 public canAddCard(userId: CuratorId): boolean { 185 // Author can always add cards 186 if (this.props.authorId.equals(userId)) { 187 return true; 188 } 189 190 // If collection is open, anyone can add cards 191 if (this.isOpen) { 192 return true; 193 } 194 195 // If collection is closed, only collaborators can add cards 196 return this.props.collaboratorIds.some((collaboratorId) => 197 collaboratorId.equals(userId), 198 ); 199 } 200 201 public addCard( 202 cardId: CardId, 203 userId: CuratorId, 204 ): Result<CardLink, CollectionAccessError> { 205 if (!this.canAddCard(userId)) { 206 return err( 207 new CollectionAccessError( 208 'User does not have permission to add cards to this collection', 209 ), 210 ); 211 } 212 213 // Check if card is already in collection 214 const existingLink = this.props.cardLinks.find((link) => 215 link.cardId.equals(cardId), 216 ); 217 if (existingLink) { 218 return ok(existingLink); // Return existing link 219 } 220 221 const newLink: CardLink = { 222 cardId, 223 addedBy: userId, 224 addedAt: new Date(), 225 publishedRecordId: undefined, // Will be set when published 226 }; 227 228 this.props.cardLinks.push(newLink); 229 this.props.cardCount = this.props.cardLinks.length; 230 this.props.updatedAt = new Date(); 231 232 // Raise domain event 233 this.addDomainEvent( 234 CardAddedToCollectionEvent.create( 235 cardId, 236 this.collectionId, 237 userId, 238 ).unwrap(), 239 ); 240 241 return ok(newLink); 242 } 243 244 public markCardLinkAsPublished( 245 cardId: CardId, 246 publishedRecordId: PublishedRecordId, 247 ): void { 248 const link = this.props.cardLinks.find((link) => 249 link.cardId.equals(cardId), 250 ); 251 if (link) { 252 link.publishedRecordId = publishedRecordId; 253 this.props.updatedAt = new Date(); 254 } 255 } 256 257 public removeCard( 258 cardId: CardId, 259 userId: CuratorId, 260 ): Result<void, CollectionAccessError> { 261 if (!this.canAddCard(userId)) { 262 return err( 263 new CollectionAccessError( 264 'User does not have permission to remove cards from this collection', 265 ), 266 ); 267 } 268 269 this.props.cardLinks = this.props.cardLinks.filter( 270 (link) => !link.cardId.equals(cardId), 271 ); 272 this.props.cardCount = this.props.cardLinks.length; 273 this.props.updatedAt = new Date(); 274 275 return ok(undefined); 276 } 277 278 public addCollaborator( 279 collaboratorId: CuratorId, 280 userId: CuratorId, 281 ): Result<void, CollectionAccessError> { 282 if (!this.props.authorId.equals(userId)) { 283 return err( 284 new CollectionAccessError('Only the author can add collaborators'), 285 ); 286 } 287 288 if (this.props.collaboratorIds.some((id) => id.equals(collaboratorId))) { 289 return ok(undefined); // Already a collaborator 290 } 291 292 this.props.collaboratorIds.push(collaboratorId); 293 this.props.updatedAt = new Date(); 294 295 return ok(undefined); 296 } 297 298 public removeCollaborator( 299 collaboratorId: CuratorId, 300 userId: CuratorId, 301 ): Result<void, CollectionAccessError> { 302 if (!this.props.authorId.equals(userId)) { 303 return err( 304 new CollectionAccessError('Only the author can remove collaborators'), 305 ); 306 } 307 308 this.props.collaboratorIds = this.props.collaboratorIds.filter( 309 (id) => !id.equals(collaboratorId), 310 ); 311 this.props.updatedAt = new Date(); 312 313 return ok(undefined); 314 } 315 316 public changeAccessType( 317 accessType: CollectionAccessType, 318 userId: CuratorId, 319 ): Result<void, CollectionAccessError> { 320 if (!this.props.authorId.equals(userId)) { 321 return err( 322 new CollectionAccessError( 323 'Only the author can change collection access type', 324 ), 325 ); 326 } 327 328 this.props.accessType = accessType; 329 this.props.updatedAt = new Date(); 330 331 return ok(undefined); 332 } 333 334 public markAsPublished(publishedRecordId: PublishedRecordId): void { 335 this.props.publishedRecordId = publishedRecordId; 336 this.props.updatedAt = new Date(); 337 } 338 339 public markAsUnpublished(): void { 340 this.props.publishedRecordId = undefined; 341 this.props.updatedAt = new Date(); 342 } 343 344 public updateDetails( 345 name: string, 346 description?: string, 347 ): Result<void, CollectionValidationError> { 348 // Validate and create CollectionName 349 const nameResult = CollectionName.create(name); 350 if (nameResult.isErr()) { 351 return err(new CollectionValidationError(nameResult.error.message)); 352 } 353 354 // Validate and create CollectionDescription if provided 355 let newDescription: CollectionDescription | undefined; 356 if (description) { 357 const descriptionResult = CollectionDescription.create(description); 358 if (descriptionResult.isErr()) { 359 return err( 360 new CollectionValidationError(descriptionResult.error.message), 361 ); 362 } 363 newDescription = descriptionResult.value; 364 } 365 366 // Update properties 367 this.props.name = nameResult.value; 368 this.props.description = newDescription; 369 this.props.updatedAt = new Date(); 370 371 return ok(undefined); 372 } 373 374 public getUnpublishedCardLinks(): CardLink[] { 375 return this.props.cardLinks.filter((link) => !link.publishedRecordId); 376 } 377}